diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 81c3a9c1a..f262fd451 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -31,6 +31,7 @@ def privacy_level_query(user, lookup_field="privacy_level", user_field="user"): } ) # Federated TrackFavorite don't have an user associated with the trackfavorite.actor + # to do : if we implement the followers privacy_level this will become a problem no_user_query = models.Q(**{f"{user_field}__isnull": True}) return ( diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index b6500e741..832c0f660 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -76,11 +76,13 @@ class PrivacyLevelPermission(BasePermission): # to avoid leaking data (#2326) return True - privacy_level = ( - obj.actor.user.privacy_level - if hasattr(obj, "actor") - else obj.user.privacy_level - ) + if hasattr(obj, "privacy_level"): + privacy_level = obj.privacy_level + elif hasattr(obj, "actor") and obj.actor.user: + privacy_level = obj.actor.user.privacy_level + else: + privacy_level = obj.user.privacy_level + obj_actor = obj.actor if hasattr(obj, "actor") else obj.user.actor if privacy_level == "everyone": @@ -106,9 +108,7 @@ class PrivacyLevelPermission(BasePermission): elif privacy_level == "me" and obj_actor == request_actor: return True - elif privacy_level == "followers" and ( - request_actor in obj.user.actor.get_approved_followers() - ): + elif request_actor in obj_actor.get_approved_followers(): return True else: return False diff --git a/api/funkwhale_api/common/schema.yml b/api/funkwhale_api/common/schema.yml index 81bee930d..3dd623900 100644 --- a/api/funkwhale_api/common/schema.yml +++ b/api/funkwhale_api/common/schema.yml @@ -6929,7 +6929,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v1/playlists/{id}/: + /api/v1/playlists/{uuid}/: get: operationId: get_playlist parameters: @@ -6941,10 +6941,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -6972,10 +6972,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7018,10 +7018,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7063,10 +7063,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7076,7 +7076,7 @@ paths: responses: '204': description: No response body - /api/v1/playlists/{id}/add/: + /api/v1/playlists/{uuid}/add/: post: operationId: add_to_playlist parameters: @@ -7088,10 +7088,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7123,7 +7123,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v1/playlists/{id}/albums/: + /api/v1/playlists/{uuid}/albums/: get: operationId: get_playlist_albums parameters: @@ -7135,10 +7135,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7155,9 +7155,9 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v1/playlists/{id}/artists/: + /api/v1/playlists/{uuid}/artists/: get: - operationId: get_playlist_artits + operationId: get_playlist_artists parameters: - in: query name: format @@ -7167,10 +7167,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7187,7 +7187,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v1/playlists/{id}/clear/: + /api/v1/playlists/{uuid}/clear/: delete: operationId: clear_playlist parameters: @@ -7199,10 +7199,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7212,7 +7212,7 @@ paths: responses: '204': description: No response body - /api/v1/playlists/{id}/move/: + /api/v1/playlists/{uuid}/move/: post: operationId: reorder_track_in_playlist parameters: @@ -7224,10 +7224,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7259,7 +7259,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v1/playlists/{id}/remove/: + /api/v1/playlists/{uuid}/remove/: post: operationId: remove_from_playlist_2 parameters: @@ -7271,10 +7271,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7317,10 +7317,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -7330,7 +7330,7 @@ paths: responses: '204': description: No response body - /api/v1/playlists/{id}/tracks/: + /api/v1/playlists/{uuid}/tracks/: get: operationId: get_playlist_tracks parameters: @@ -7349,12 +7349,6 @@ paths: enum: - json - xspf - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this playlist. - required: true - in: query name: name schema: @@ -7397,6 +7391,12 @@ paths: name: track schema: type: integer + - in: path + name: uuid + schema: + type: string + format: uuid + required: true tags: - playlists security: @@ -16474,7 +16474,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v2/playlists/{id}/: + /api/v2/playlists/{uuid}/: get: operationId: get_playlist_2 parameters: @@ -16486,10 +16486,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16517,10 +16517,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16563,10 +16563,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16608,10 +16608,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16621,7 +16621,7 @@ paths: responses: '204': description: No response body - /api/v2/playlists/{id}/add/: + /api/v2/playlists/{uuid}/add/: post: operationId: add_to_playlist_2 parameters: @@ -16633,10 +16633,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16668,7 +16668,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v2/playlists/{id}/albums/: + /api/v2/playlists/{uuid}/albums/: get: operationId: get_playlist_albums_2 parameters: @@ -16680,10 +16680,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16700,9 +16700,9 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v2/playlists/{id}/artists/: + /api/v2/playlists/{uuid}/artists/: get: - operationId: get_playlist_artits_2 + operationId: get_playlist_artists_2 parameters: - in: query name: format @@ -16712,10 +16712,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16732,7 +16732,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v2/playlists/{id}/clear/: + /api/v2/playlists/{uuid}/clear/: delete: operationId: clear_playlist_2 parameters: @@ -16744,10 +16744,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16757,7 +16757,7 @@ paths: responses: '204': description: No response body - /api/v2/playlists/{id}/move/: + /api/v2/playlists/{uuid}/move/: post: operationId: reorder_track_in_playlist_2 parameters: @@ -16769,10 +16769,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16804,7 +16804,7 @@ paths: schema: $ref: '#/components/schemas/Playlist' description: '' - /api/v2/playlists/{id}/remove/: + /api/v2/playlists/{uuid}/remove/: post: operationId: remove_from_playlist_4 parameters: @@ -16816,10 +16816,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16862,10 +16862,10 @@ paths: - json - xspf - in: path - name: id + name: uuid schema: - type: integer - description: A unique integer value identifying this playlist. + type: string + format: uuid required: true tags: - playlists @@ -16875,7 +16875,7 @@ paths: responses: '204': description: No response body - /api/v2/playlists/{id}/tracks/: + /api/v2/playlists/{uuid}/tracks/: get: operationId: get_playlist_tracks_2 parameters: @@ -16894,12 +16894,6 @@ paths: enum: - json - xspf - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this playlist. - required: true - in: query name: name schema: @@ -16942,6 +16936,12 @@ paths: name: track schema: type: integer + - in: path + name: uuid + schema: + type: string + format: uuid + required: true tags: - playlists security: @@ -24813,8 +24813,13 @@ components: Playlist: type: object properties: - id: - type: integer + uuid: + type: string + format: uuid + readOnly: true + fid: + type: string + format: uri readOnly: true name: type: string @@ -24851,16 +24856,26 @@ components: type: string nullable: true maxLength: 5000 + library: + type: string + format: uri + readOnly: true + library_followed: + type: boolean + readOnly: true required: - actor - album_covers - creation_date - duration - - id + - fid - is_playable + - library + - library_followed - modification_date - name - tracks_count + - uuid PlaylistAddManyRequest: type: object properties: diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 270e6857a..b01355493 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -405,6 +405,7 @@ class Fetch(models.Model): serializers.ChannelUploadSerializer, ], contexts.FW.Library: [serializers.LibrarySerializer], + contexts.FW.Playlist: [serializers.PlaylistSerializer], contexts.AS.Group: [serializers.ActorSerializer], contexts.AS.Person: [serializers.ActorSerializer], contexts.AS.Organization: [serializers.ActorSerializer], diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 441688fad..4c3dc4fac 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -679,9 +679,6 @@ def inbox_delete_favorite(payload, context): favorite.delete() -# to do : test listening routes and broadcast - - @outbox.register({"type": "Listen", "object.type": "Track"}) def outbox_create_listening(context): track = context["track"] @@ -807,18 +804,19 @@ def inbox_delete_playlist(payload, context): @inbox.register({"type": "Update", "object.type": "Playlist"}) def inbox_update_playlist(payload, context): - actor = context["actor"] - playlist_id = payload["object"].get("id") + """If we receive an update on an unkwnown playlist, we create the playlist""" - if not actor.playlists.filter(fid=playlist_id).exists(): - logger.debug("Discarding update of unkwnown playlist_id %s", playlist_id) - return + playlist_id = payload["object"].get("id") serializer = serializers.PlaylistSerializer(data=payload["object"]) if serializer.is_valid(raise_exception=True): playlist = serializer.save() + # we update the playlist.library to get the plt.track.uploads locally + if follows := playlist.library.received_follows.filter(approved=True): + playlist.library.schedule_scan(follows[0].actor, force=True) # we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities - playlist.schedule_scan(actors.get_service_actor()) + playlist.schedule_scan(actors.get_service_actor(), force=True) + return else: logger.debug( diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 99e47df5d..dcdaca87d 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -940,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} def get_additional_fields(data): UNSET = object() additional_fields = {} - for field in ["name", "summary"]: + for field in ["name", "summary", "library", "audience", "published"]: v = data.get(field, UNSET) if v == UNSET: continue + # in some cases we use the serializer context to pass objects instances, we don't want to add them + if not isinstance(v, str) or isinstance(v, dict): + continue additional_fields[field] = v return additional_fields @@ -1037,7 +1040,11 @@ class LibrarySerializer(PaginatedCollectionSerializer): "page_size": 100, "attributedTo": library.actor, "actor": library.actor, - "items": library.uploads.for_federation(), + "items": ( + library.uploads.for_federation() + if not library.playlist_uploads.all() + else library.playlist_uploads.for_federation() + ), "type": "Library", } r = super().to_representation(conf) @@ -1129,7 +1136,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): "last": last, "items": [ conf["item_serializer"]( - i, context={"actor": conf["actor"], "include_ap_context": False} + i, + context={ + "actor": conf["actor"], + "library": conf.get("library", None), + "include_ap_context": False, + }, ).data for i in page.object_list ], @@ -1670,8 +1682,9 @@ class UploadSerializer(jsonld.JsonLdSerializer): def validate_library(self, v): lb = self.context.get("library") if lb: - if lb.fid != v: - raise serializers.ValidationError("Invalid library") + # the upload can come from a playlist lib + if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v: + raise serializers.ValidationError("Invalid library fid") return lb actor = self.context.get("actor") @@ -1683,10 +1696,10 @@ class UploadSerializer(jsonld.JsonLdSerializer): queryset=music_models.Library, serializer_class=LibrarySerializer, ) - except Exception: - raise serializers.ValidationError("Invalid library") + except Exception as e: + raise serializers.ValidationError(f"Invalid library : {e}") if actor and library.actor != actor: - raise serializers.ValidationError("Invalid library") + raise serializers.ValidationError("Invalid library, actor check fails") return library def update(self, instance, validated_data): @@ -1737,11 +1750,12 @@ class UploadSerializer(jsonld.JsonLdSerializer): return music_models.Upload.objects.create(**data) def to_representation(self, instance): + lib = instance.library if instance.library else self.context.get("library") track = instance.track d = { "type": "Audio", "id": instance.get_federation_id(), - "library": instance.library.fid, + "library": lib.fid, "name": track.full_name, "published": instance.creation_date.isoformat(), "bitrate": instance.bitrate, @@ -1760,12 +1774,8 @@ class UploadSerializer(jsonld.JsonLdSerializer): }, ], "track": TrackSerializer(track, context={"include_ap_context": False}).data, - "to": ( - contexts.AS.Public - if instance.library.privacy_level == "everyone" - else "" - ), - "attributedTo": instance.library.actor.fid, + "to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""), + "attributedTo": lib.actor.fid, } if instance.modification_date: d["updated"] = instance.modification_date.isoformat() @@ -2325,7 +2335,7 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer): validated_data["playlist"], actor=self.context.get("fetch_actor"), queryset=playlists_models.Playlist, - serializer_class=PlaylistTrackSerializer, + serializer_class=PlaylistSerializer, ) defaults = { @@ -2334,6 +2344,10 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer): "creation_date": validated_data["creation_date"], "playlist": playlist, } + if existing_plt := playlists_models.PlaylistTrack.objects.filter( + playlist=playlist, index=validated_data["index"] + ): + existing_plt.delete() plt, created = playlists_models.PlaylistTrack.objects.update_or_create( defaults, @@ -2342,7 +2356,6 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer): "fid": validated_data["id"], }, ) - return plt @@ -2364,6 +2377,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): allow_null=True, allow_blank=True, ) + library = serializers.URLField(max_length=500, required=True) updateable_fields = [ ("name", "title"), ("attributedTo", "attributed_to"), @@ -2377,6 +2391,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): "updated": jsonld.first_val(contexts.AS.published), "audience": jsonld.first_id(contexts.AS.audience), "attributedTo": jsonld.first_id(contexts.AS.attributedTo), + "library": jsonld.first_id(contexts.FW.library), }, ) @@ -2388,6 +2403,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): "attributedTo": playlist.actor.fid, "published": playlist.creation_date.isoformat(), "audience": playlist.privacy_level, + "library": playlist.library.fid, } payload["audience"] = ( contexts.AS.Public if playlist.privacy_level == "everyone" else "" @@ -2405,12 +2421,22 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): queryset=models.Actor, serializer_class=ActorSerializer, ) + + library = utils.retrieve_ap_object( + validated_data["library"], + actor=self.context.get("fetch_actor"), + queryset=music_models.Library, + serializer_class=LibrarySerializer, + ) + ap_to_fw_data = { "actor": actor, "name": validated_data["name"], "creation_date": validated_data["published"], "privacy_level": validated_data["audience"], + "library": library, } + playlist, created = playlists_models.Playlist.objects.update_or_create( defaults=ap_to_fw_data, **{ @@ -2420,19 +2446,23 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): ), }, ) + return playlist def validate(self, data): validated_data = super().validate(data) - if validated_data["audience"] not in [ + if validated_data["audience"] in [ "https://www.w3.org/ns/activitystreams#Public", "everyone", ]: - raise serializers.ValidationError("Privacy_level must be everyone") - - validated_data["audience"] = "everyone" + validated_data["audience"] = "everyone" + else: + validated_data.pop("audience") return validated_data + def update(self, instance, validated_data): + return self.create(validated_data) + class PlaylistCollectionSerializer(PaginatedCollectionSerializer): """ @@ -2451,6 +2481,8 @@ class PlaylistCollectionSerializer(PaginatedCollectionSerializer): "tracks", ), "type": "Playlist", + "library": playlist.library.fid, + "published": playlist.creation_date.isoformat(), } r = super().to_representation(conf) return r diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index 78f321864..e5761534e 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -23,6 +23,8 @@ music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") music_router.register(r"likes", views.TrackFavoriteViewSet, "likes") music_router.register(r"listenings", views.ListeningsViewSet, "listenings") music_router.register(r"playlists", views.PlaylistViewSet, "playlists") +music_router.register(r"playlists", views.PlaylistTrackViewSet, "playlist-tracks") + index_router.register(r"index", views.IndexViewSet, "index") diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index cb6f26ebb..8b6617132 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -365,6 +365,20 @@ def has_library_access(request, library): return library.received_follows.filter(actor=actor, approved=True).exists() +def has_playlist_access(request, playlist): + if playlist.privacy_level == "everyone": + return True + if request.user.is_authenticated and request.user.is_superuser: + return True + + try: + actor = request.actor + except AttributeError: + return False + + return playlist.library.received_follows.filter(actor=actor, approved=True).exists() + + class MusicLibraryViewSet( FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): @@ -383,13 +397,16 @@ class MusicLibraryViewSet( lb = self.get_object() if utils.should_redirect_ap_to_html(request.headers.get("accept")): return redirect_to_html(lb.get_absolute_url()) + items_qs = ( + lb.uploads.for_federation() + if not lb.playlist_uploads.all() + else lb.playlist_uploads.for_federation() + ) conf = { "id": lb.get_federation_id(), "actor": lb.actor, "name": lb.name, - "items": lb.uploads.for_federation() - .order_by("-creation_date") - .prefetch_related( + "items": items_qs.order_by("-creation_date").prefetch_related( Prefetch( "track", queryset=music_models.Track.objects.select_related( @@ -413,8 +430,8 @@ class MusicLibraryViewSet( ) ), "item_serializer": serializers.UploadSerializer, + "library": lb, } - return get_collection_response( conf=conf, querystring=request.GET, @@ -709,7 +726,6 @@ class PlaylistViewSet( FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): authentication_classes = [authentication.SignatureAuthentication] - permission_classes = [common_permissions.PrivacyLevelPermission] renderer_classes = renderers.get_ap_renderers() queryset = playlists_models.Playlist.objects.local().select_related("actor") serializer_class = serializers.PlaylistCollectionSerializer @@ -728,9 +744,31 @@ class PlaylistViewSet( "track", ), "item_serializer": serializers.PlaylistTrackSerializer, + "library": playlist.library.fid, } return get_collection_response( conf=conf, querystring=request.GET, collection_serializer=serializers.PlaylistCollectionSerializer(playlist), + page_access_check=lambda: has_playlist_access(request, playlist), ) + + +class PlaylistTrackViewSet( + FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + authentication_classes = [authentication.SignatureAuthentication] + renderer_classes = renderers.get_ap_renderers() + queryset = playlists_models.PlaylistTrack.objects.local().select_related("actor") + serializer_class = serializers.PlaylistTrackSerializer + lookup_field = "uuid" + + def retrieve(self, request, *args, **kwargs): + plt = self.get_object() + if not has_playlist_access(request, plt.playlist): + return response.Response(status=403) + if utils.should_redirect_ap_to_html(request.headers.get("accept")): + return redirect_to_html(plt.get_absolute_url()) + + serializer = self.get_serializer(plt) + return response.Response(serializer.data) diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 12b413c98..a014a46ac 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -98,6 +98,14 @@ class UploadAdmin(admin.ModelAdmin): ] list_filter = ["mimetype", "import_status", "library__privacy_level"] + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == "playlist_libraries": + object_id = request.resolver_match.kwargs.get("object_id") + kwargs["queryset"] = models.Library.objects.filter( + playlist_uploads=object_id + ).distinct() + return super().formfield_for_foreignkey(db_field, request, **kwargs) + @admin.register(models.UploadVersion) class UploadVersionAdmin(admin.ModelAdmin): diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py index 9f74c1f72..b463ad049 100644 --- a/api/funkwhale_api/music/fake_data.py +++ b/api/funkwhale_api/music/fake_data.py @@ -47,7 +47,7 @@ def create_data(super_user_name=None): library = federation_factories.MusicLibraryFactory( actor=(super_user.actor if super_user else federation_factories.ActorFactory()), - local=True, + local=True if super_user else False, ) uploads = music_factories.UploadFactory.create_batch( size=random.randint(3, 18), @@ -68,6 +68,7 @@ def create_data(super_user_name=None): playlist = playlist_factories.PlaylistFactory( name="playlist test public", privacy_level="everyone", + local=True if super_user else False, actor=(super_user.actor if super_user else federation_factories.ActorFactory()), ) playlist_factories.PlaylistTrackFactory(playlist=playlist, track=upload.track) @@ -112,7 +113,7 @@ def create_data(super_user_name=None): # my artist channel my_artist_library = federation_factories.MusicLibraryFactory( actor=(super_user.actor if super_user else federation_factories.ActorFactory()), - local=True, + local=True if super_user else False, ) my_artist_channel = audio_factories.ChannelFactory( library=my_artist_library, diff --git a/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py b/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py index 5c857ddcd..71362abfd 100644 --- a/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py +++ b/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.9 on 2025-01-03 16:12 -from django.db import migrations, models -from django.db import IntegrityError +from django.db import migrations, models, transaction +from django.conf import settings from funkwhale_api.federation import utils as federation_utils from django.urls import reverse @@ -10,101 +10,199 @@ import uuid def insert_tracks_to_playlist(apps, playlist, uploads): PlaylistTrack = apps.get_model("playlists", "PlaylistTrack") - plts = [ - PlaylistTrack( - creation_date=playlist.creation_date, - playlist=playlist, - track=upload.track, - index=0 + i, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - f"federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - for i, upload in enumerate(uploads) - if upload.track - ] - - return PlaylistTrack.objects.bulk_create(plts) - - -def migrate_libraries_to_playlist(apps, schema_editor): - Playlist = apps.get_model("playlists", "Playlist") - Library = apps.get_model("music", "Library") - LibraryFollow = apps.get_model("federation", "LibraryFollow") - Follow = apps.get_model("federation", "Follow") - User = apps.get_model("users", "User") - Actor = apps.get_model("federation", "Actor") - - # library to playlist - for library in Library.objects.all(): - playlist = Playlist.objects.create( - name=library.name, - actor=library.actor, - creation_date=library.creation_date, - privacy_level=library.privacy_level, - description=library.description, - uuid=(new_uuid := uuid.uuid4()), - fid=federation_utils.full_url( - reverse( - f"federation:music:playlists-detail", - kwargs={"uuid": new_uuid}, - ) - ), - ) - playlist.save() - - if library.uploads.all().exists(): - insert_tracks_to_playlist(apps, playlist, library.uploads.all()) - - # library follows to user follow - for lib_follow in LibraryFollow.objects.filter(target=library): - try: - Follow.objects.create( - uuid=lib_follow.uuid, - target=library.actor, - actor=lib_follow.actor, - approved=lib_follow.approved, - creation_date=lib_follow.creation_date, - modification_date=lib_follow.modification_date, - ) - except IntegrityError: - pass - - LibraryFollow.objects.all().delete() - - # migrate uploads to new library - for actor in Actor.objects.all(): - privacy_levels = ["me", "instance", "everyone"] - for privacy_level in privacy_levels: - build_in_lib = Library.objects.create( - actor=actor, - privacy_level=privacy_level, - name=privacy_level, + for i, upload in enumerate(uploads): + if upload.track: + PlaylistTrack.objects.create( + creation_date=playlist.creation_date, + playlist=playlist, + track=upload.track, + index=0 + i, uuid=(new_uuid := uuid.uuid4()), fid=federation_utils.full_url( reverse( - f"federation:music:libraries-detail", + "federation:music:playlist-tracks-detail", kwargs={"uuid": new_uuid}, ) ), ) + upload.library = None + upload.save() + + playlist.library.playlist_uploads.set(uploads) + + +@transaction.atomic +def migrate_libraries_to_playlist(apps, schema_editor): + Playlist = apps.get_model("playlists", "Playlist") + Library = apps.get_model("music", "Library") + Actor = apps.get_model("federation", "Actor") + Channel = apps.get_model("audio", "Channel") + + to_instance_libs = [] + to_public_libs = [] + to_me_libs = [] + for library in Library.objects.all(): + if ( + not federation_utils.is_local(library.actor.fid) + or library.actor.name == "service" + ): + continue + + if ( + hasattr(library, "playlist") + and library.playlist + and library.uploads.all().exists() + ): + uploads = library.uploads.all() + with transaction.atomic(): + insert_tracks_to_playlist(apps, library.playlist, uploads) + continue + + if ( + Channel.objects.filter(library=library).exists() + or Playlist.objects.filter(library=library).exists() + or not federation_utils.is_local(library.fid) + or library.name in ["me", "instance", "everyone"] + ): + continue + + try: + playlist, created = Playlist.objects.get_or_create( + name=library.name, + library=library, + actor=library.actor, + creation_date=library.creation_date, + privacy_level=library.privacy_level, + description=library.description, + defaults={ + "uuid": (new_uuid := uuid.uuid4()), + "fid": federation_utils.full_url( + reverse( + "federation:music:playlists-detail", + kwargs={"uuid": new_uuid}, + ) + ), + }, + ) + playlist.save() + + if library.uploads.all().exists(): + uploads = library.uploads.all() + with transaction.atomic(): + insert_tracks_to_playlist(apps, playlist, uploads) + + if library.privacy_level == "me": + to_me_libs.append(library) + if library.privacy_level == "instance": + to_instance_libs.append(library) + if library.privacy_level == "everyone": + to_public_libs.append(library) + + library.privacy_level = "me" + library.playlist = playlist + library.save() + except Exception as e: + print(f"An error occurred during library.playlist creation : {e}") + continue + + # migrate uploads to new built-in libraries + for actor in Actor.objects.all(): + if ( + not federation_utils.is_local(actor.fid) + or actor.name == "service" + or hasattr(actor, "channel") + ): + continue + + privacy_levels = ["me", "instance", "everyone"] + for privacy_level in privacy_levels: + build_in_lib, created = Library.objects.filter( + channel__isnull=True + ).get_or_create( + actor=actor, + privacy_level=privacy_level, + name=privacy_level, + defaults={ + "uuid": (new_uuid := uuid.uuid4()), + "fid": federation_utils.full_url( + reverse( + "federation:music:libraries-detail", + kwargs={"uuid": new_uuid}, + ) + ), + }, + ) for library in actor.libraries.filter(privacy_level=privacy_level): library.uploads.all().update(library=build_in_lib) - if library.pk is not build_in_lib.pk: - library.delete() + library.delete + + if privacy_level == "everyone": + for lib in to_public_libs: + lib.uploads.all().update(library=build_in_lib) + if privacy_level == "instance": + for lib in to_instance_libs: + lib.uploads.all().update(library=build_in_lib) + if privacy_level == "me": + for lib in to_me_libs: + lib.uploads.all().update(library=build_in_lib) + + +def check_succefull_migration(apps, schema_editor): + Actor = apps.get_model("federation", "Actor") + Playlist = apps.get_model("playlists", "Playlist") + + for actor in Actor.objects.all(): + not_build_in_libs = ( + actor.playlists.count() + + actor.libraries.filter(channel__isnull=False).count() + ) + + if actor.name == "service" or not federation_utils.is_local(actor.fid): + continue + + elif actor.playlists.filter(library__isnull=True).count() > 0: + raise Exception( + f"Incoherent playlist database state : all local playlists do not have lib or too many libs" + ) + elif ( + not hasattr(actor, "channel") + and actor.libraries.count() - 3 != not_build_in_libs + or (hasattr(actor, "channel") and actor.libraries.count() > 1) + ): + raise Exception( + f"Incoherent library database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss.\ + actor libs = {actor.libraries.count()} and acto not built-in lib = {not_build_in_libs} \ + and acto pl ={actor.playlists.count()} and not channel lib = {actor.libraries.filter(channel__isnull=False).count()} \ + and actor.name = {actor.name}" + ) + for playlist in Playlist.objects.all(): + if not federation_utils.is_local(playlist.fid): + continue + elif playlist.library.privacy_level != "me": + raise Exception( + "Incoherent playlist database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss" + ) class Migration(migrations.Migration): dependencies = [ ("music", "0060_empty_for_test"), - ("playlists", "0008_playlist_library_drop"), + ("playlists", "0009_playlist_library"), ] operations = [ + migrations.AddField( + model_name="upload", + name="playlist_libraries", + field=models.ManyToManyField( + blank=True, + related_name="playlist_uploads", + to="music.library", + ), + ), migrations.RunPython( migrate_libraries_to_playlist, reverse_code=migrations.RunPython.noop ), + migrations.RunPython( + check_succefull_migration, reverse_code=migrations.RunPython.noop + ), ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fc84922a3..6b7ef5504 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -765,13 +765,14 @@ class UploadQuerySet(common_models.NullsLastQuerySet): def playable_by(self, actor, include=True): libraries = Library.objects.viewable_by(actor) - if include: return self.filter( - library__in=libraries, import_status__in=["finished", "skipped"] + Q(library__in=libraries) | Q(playlist_libraries__in=libraries), + import_status__in=["finished", "skipped"], ) return self.exclude( - library__in=libraries, import_status__in=["finished", "skipped"] + Q(library__in=libraries) | Q(playlist_libraries__in=libraries), + import_status__in=["finished", "skipped"], ) def local(self, include=True): @@ -847,6 +848,11 @@ class Upload(models.Model): related_name="uploads", on_delete=models.CASCADE, ) + playlist_libraries = models.ManyToManyField( + "library", + blank=True, + related_name="playlist_uploads", + ) # metadata from federation metadata = JSONField( diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 4a3111cb3..df0b508d3 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -555,10 +555,14 @@ class UploadBulkUpdateSerializer(serializers.Serializer): raise serializers.ValidationError( f"Upload with uuid {data['uuid']} does not exist" ) + lib = upload.library.actor.libraries.filter( + privacy_level=data["privacy_level"], name=data["privacy_level"] + ).exclude(playlist__isnull=False) - upload.library = upload.library.actor.libraries.get( - privacy_level=data["privacy_level"] - ) + if len(lib) == 1: + upload.library = lib[0] + else: + raise serializers.ValidationError("Built-in library not found or too many") return upload diff --git a/api/funkwhale_api/playlists/admin.py b/api/funkwhale_api/playlists/admin.py index 2cc83d4fe..014d0bab5 100644 --- a/api/funkwhale_api/playlists/admin.py +++ b/api/funkwhale_api/playlists/admin.py @@ -15,3 +15,22 @@ class PlaylistTrackAdmin(admin.ModelAdmin): list_display = ["playlist", "track", "index"] search_fields = ["track__name", "playlist__name"] list_select_related = True + + +@admin.register(models.PlaylistScan) +class LibraryScanAdmin(admin.ModelAdmin): + list_display = [ + "id", + "playlist", + "actor", + "status", + "creation_date", + "modification_date", + "status", + "total_files", + "processed_files", + "errored_files", + ] + list_select_related = True + search_fields = ["actor__username", "playlist__name"] + list_filter = ["status"] diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py index cf31be03f..6ba1087d6 100644 --- a/api/funkwhale_api/playlists/factories.py +++ b/api/funkwhale_api/playlists/factories.py @@ -3,7 +3,7 @@ from django.conf import settings from funkwhale_api.factories import NoUpdateOnCreate, registry from funkwhale_api.federation import models -from funkwhale_api.federation.factories import ActorFactory +from funkwhale_api.federation.factories import ActorFactory, MusicLibraryFactory from funkwhale_api.music.factories import TrackFactory @@ -13,6 +13,7 @@ class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): actor = factory.SubFactory(ActorFactory) fid = factory.Faker("federation_url") uuid = factory.Faker("uuid4") + library = factory.SubFactory(MusicLibraryFactory) class Meta: model = "playlists.Playlist" diff --git a/api/funkwhale_api/playlists/migrations/0009_playlist_library.py b/api/funkwhale_api/playlists/migrations/0009_playlist_library.py new file mode 100644 index 000000000..2457d551c --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0009_playlist_library.py @@ -0,0 +1,86 @@ +import django.db.models.deletion +from django.db import migrations, models, transaction +from funkwhale_api.federation import utils as federation_utils +from django.urls import reverse +import uuid +from django.conf import settings + + +def add_uploads_to_pl_library(playlist, library): + for plt in playlist.playlist_tracks.all(): + for upload in plt.track.uploads.filter(library__actor=playlist.actor): + library.uploads.add(upload) + + +@transaction.atomic +def create_playlist_libraries(apps, schema_editor): + Playlist = apps.get_model("playlists", "Playlist") + Library = apps.get_model("music", "Library") + Actor = apps.get_model("federation", "Actor") + playlist_with_lib_count = 0 + + for playlist in Playlist.objects.all(): + if not federation_utils.is_local(playlist.actor.fid): + continue + library = playlist.library + if not library: + try: + # we don't want to get_or_create in case it's a channel lib + library = Library.objects.create( + name="playlist_" + playlist.name, + privacy_level="me", + actor=playlist.actor, + uuid=(new_uuid := uuid.uuid4()), + fid=federation_utils.full_url( + reverse( + "federation:music:libraries-detail", + kwargs={"uuid": new_uuid}, + ) + ), + ) + library.save() + playlist.library = library + playlist.save() + with transaction.atomic(): + add_uploads_to_pl_library(playlist, library) + except Exception as e: + print( + f"An error occurred during playlist.library creation, raising since we want\ + to enforce one lib per playlist" + ) + raise e + playlist_with_lib_count = playlist_with_lib_count + 1 + local_actors = Actor.objects.filter(domain_id=settings.FEDERATION_HOSTNAME) + + if ( + Library.objects.filter( + playlist__isnull=False, actor__in=local_actors + ).count() + != playlist_with_lib_count + ): + raise Exception( + "Should have the same amount of local playlist and libraries with playlist" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("playlists", "0008_playlist_library_drop"), + ] + + operations = [ + migrations.AddField( + model_name="playlist", + name="library", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="playlist", + to="music.library", + ), + ), + migrations.RunPython( + create_playlist_libraries, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index d70d0b2a3..2370f53c0 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -88,12 +88,19 @@ class Playlist(federation_models.FederationMixin): description = models.TextField(max_length=5000, null=True, blank=True) objects = PlaylistQuerySet.as_manager() federation_namespace = "playlists" + library = models.OneToOneField( + "music.Library", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="playlist", + ) def __str__(self): return self.name def get_absolute_url(self): - return f"/library/playlists/{self.pk}" + return f"/library/playlists/{self.uuid}" def get_federation_id(self): if self.fid: @@ -109,6 +116,19 @@ class Playlist(federation_models.FederationMixin): if not self.pk and not self.fid: self.fid = self.get_federation_id() + if not self.pk and not self.library_id: + self.library = music_models.Library.objects.create( + actor=self.actor, + name="playlist_" + self.name, + privacy_level="me", + uuid=(new_uuid := uuid.uuid4()), + fid=federation_utils.full_url( + reverse( + "federation:music:libraries-detail", kwargs={"uuid": new_uuid} + ), + ), + ) + return super().save(**kwargs) @transaction.atomic @@ -232,7 +252,7 @@ class Playlist(federation_models.FederationMixin): latest_scan = ( self.scans.exclude(status="errored").order_by("-creation_date").first() ) - delay_between_scans = datetime.timedelta(seconds=3600 * 24) + delay_between_scans = datetime.timedelta(seconds=1) now = timezone.now() if ( not force @@ -345,6 +365,9 @@ class PlaylistTrack(federation_models.FederationMixin): return super().save(**kwargs) + def get_absolute_url(self): + return f"/library/tracks/{self.track.pk}" + class PlaylistScan(models.Model): actor = models.ForeignKey( diff --git a/api/funkwhale_api/playlists/renderers.py b/api/funkwhale_api/playlists/renderers.py index 0abe6ed8b..65013496c 100644 --- a/api/funkwhale_api/playlists/renderers.py +++ b/api/funkwhale_api/playlists/renderers.py @@ -15,7 +15,7 @@ class PlaylistXspfRenderer(renderers.BaseRenderer): if isinstance(data, bytes): return data - fw_playlist = Playlist.objects.get(id=data["id"]) + fw_playlist = Playlist.objects.get(uuid=data["uuid"]) plt_tracks = fw_playlist.playlist_tracks.prefetch_related("track") top = Element("playlist", version="1", xmlns="http://xspf.org/ns/0/") title_xspf = SubElement(top, "title") diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index b592ef69c..c1c87bf35 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -34,11 +34,14 @@ class PlaylistSerializer(serializers.ModelSerializer): album_covers = serializers.SerializerMethodField(read_only=True) is_playable = serializers.SerializerMethodField() actor = APIActorSerializer(read_only=True) + library = serializers.SerializerMethodField() + library_followed = serializers.SerializerMethodField() class Meta: model = models.Playlist fields = ( - "id", + "uuid", + "fid", "name", "actor", "modification_date", @@ -50,8 +53,34 @@ class PlaylistSerializer(serializers.ModelSerializer): "is_playable", "actor", "description", + "library", + "library_followed", ) - read_only_fields = ["id", "modification_date", "creation_date"] + read_only_fields = ["uuid", "fid", "modification_date", "creation_date"] + + @extend_schema_field(OpenApiTypes.URI) + def get_library(self, obj): + if obj.library: + return obj.library.fid + else: + return None + + @extend_schema_field(OpenApiTypes.BOOL) + def get_library_followed(self, obj): + if self.context.get("request", False) and hasattr( + self.context["request"], "user" + ): + actor = self.context["request"].user.actor + lib_qs = obj.library.received_follows.filter(actor=actor) + + if lib_qs.exists(): + if lib_qs[0].approved is None: + return False + else: + return lib_qs[0].approved + else: + return None + return None @extend_schema_field(OpenApiTypes.BOOL) def get_is_playable(self, obj): diff --git a/api/funkwhale_api/playlists/tasks.py b/api/funkwhale_api/playlists/tasks.py index fd6d51f2d..09d7a5212 100644 --- a/api/funkwhale_api/playlists/tasks.py +++ b/api/funkwhale_api/playlists/tasks.py @@ -1,3 +1,5 @@ +import logging + import requests from django.db.models import F from django.utils import timezone @@ -9,6 +11,8 @@ from funkwhale_api.taskapp import celery from . import models +logger = logging.getLogger(__name__) + def get_playlist_data(playlist_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) @@ -24,7 +28,11 @@ def get_playlist_data(playlist_url, actor): if scode == 401: return {"errors": ["This playlist requires authentication"]} elif scode == 403: - return {"errors": ["Permission denied while scanning playlist"]} + return { + "errors": [ + f"Permission denied while scanning playlist. Error : {scode}. PLaylist url = {playlist_url}" + ] + } elif scode >= 400: return {"errors": [f"Error {scode} while fetching the playlist"]} serializer = serializers.PlaylistCollectionSerializer(data=response.json()) @@ -47,6 +55,7 @@ def get_playlist_page(playlist, page_url, actor): context={ "playlist": playlist, "item_serializer": serializers.PlaylistTrackSerializer, + "conf": {"library": playlist.library}, }, ) serializer.is_valid(raise_exception=True) @@ -60,6 +69,7 @@ def get_playlist_page(playlist, page_url, actor): ) def start_playlist_scan(playlist_scan): playlist_scan.playlist.playlist_tracks.all().delete() + try: data = get_playlist_data(playlist_scan.playlist.fid, actor=playlist_scan.actor) except Exception: @@ -90,13 +100,30 @@ def start_playlist_scan(playlist_scan): ) def scan_playlist_page(playlist_scan, page_url): data = get_playlist_page(playlist_scan.playlist, page_url, playlist_scan.actor) - tracks = [] + plts = [] for item_serializer in data["items"]: - print(" item_serializer is " + str(item_serializer)) - track = item_serializer.save(playlist=playlist_scan.playlist.fid) - tracks.append(track) + try: + plt = item_serializer.save(playlist=playlist_scan.playlist.fid) + # we get any upload owned by the playlist.actor and add a m2m with playlist_libraries + upload_qs = plt.track.uploads.filter( + library__actor=playlist_scan.playlist.actor + ) + if not upload_qs: + logger.debug( + f"Could not find a upload for the playlist track {plt.track.title}. Probably the \ + playlist.library library_scan failed or was not launched by inbox_update_playlist ?" + ) + else: + upload_qs[0].playlist_libraries.add(playlist_scan.playlist.library) + logger.debug(f"Added {plt.track.title} to playlist library") + plts.append(plt) + except Exception as e: + logger.info( + f"Error while saving track to playlist {playlist_scan.playlist}: {e}" + ) + continue - playlist_scan.processed_files = F("processed_files") + len(tracks) + playlist_scan.processed_files = F("processed_files") + len(plts) playlist_scan.modification_date = timezone.now() update_fields = ["modification_date", "processed_files"] diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 98872f601..8439c3af4 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -1,4 +1,5 @@ import logging +from itertools import chain from django.db import transaction from django.db.models import Count @@ -29,6 +30,7 @@ class PlaylistViewSet( mixins.ListModelMixin, viewsets.GenericViewSet, ): + lookup_field = "uuid" serializer_class = serializers.PlaylistSerializer queryset = ( models.Playlist.objects.all() @@ -157,6 +159,7 @@ class PlaylistViewSet( ) serializer = serializers.PlaylistTrackSerializer(plts, many=True) data = {"count": len(plts), "results": serializer.data} + update_playlist_library_uploads(playlist, plts) playlist.schedule_scan(playlist.actor, force=True) return Response(data, status=201) @@ -167,7 +170,8 @@ class PlaylistViewSet( playlist = self.get_object() playlist.playlist_tracks.all().delete() playlist.save(update_fields=["modification_date"]) - playlist.schedule_scan(playlist.actor) + playlist.library.uploads.filter().delete() + playlist.schedule_scan(playlist.actor, force=True) return Response(status=204) def get_queryset(self): @@ -200,6 +204,8 @@ class PlaylistViewSet( plt = playlist.playlist_tracks.by_index(index) except models.PlaylistTrack.DoesNotExist: return Response(status=404) + for upload in plt.track.uploads.filter(playlist_libraries=playlist.library): + upload.playlist_libraries.remove(playlist.library) plt.delete(update_indexes=True) plt.playlist.schedule_scan(playlist.actor) return Response(status=204) @@ -244,7 +250,7 @@ class PlaylistViewSet( serializer = music_serializers.AlbumSerializer(releases, many=True) return Response(serializer.data, status=200) - @extend_schema(operation_id="get_playlist_artits") + @extend_schema(operation_id="get_playlist_artists") @action(methods=["get"], detail=True) @transaction.atomic def artists(self, request, *args, **kwargs): @@ -258,3 +264,13 @@ class PlaylistViewSet( artists = music_models.Artist.objects.filter(pk__in=artists_pks) serializer = music_serializers.ArtistSerializer(artists, many=True) return Response(serializer.data, status=200) + + +def update_playlist_library_uploads(playlist, plts): + uploads = list( + chain( + *[plt.track.uploads.filter(library__actor=playlist.actor) for plt in plts] + ) + ) + for upload in uploads: + upload.playlist_libraries.add(playlist.library) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index d4338a147..dec097d28 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -471,7 +471,7 @@ def create_user_libraries(user): uuid=(new_uuid := uuid.uuid4()), fid=federation_utils.full_url( reverse( - "federation:music:playlists-detail", + "federation:music:libraries-detail", kwargs={"uuid": new_uuid}, ) ), diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py index aee98d4f1..574845184 100644 --- a/api/tests/common/test_models.py +++ b/api/tests/common/test_models.py @@ -22,7 +22,7 @@ def test_mutation_fid_is_populated(factories, model, factory_args, namespace): ("music.Artist", "/library/artists/{obj.pk}"), ("music.Album", "/library/albums/{obj.pk}"), ("music.Track", "/library/tracks/{obj.pk}"), - ("playlists.Playlist", "/library/playlists/{obj.pk}"), + ("playlists.Playlist", "/library/playlists/{obj.uuid}"), ], ) def test_get_absolute_url(factory_name, factories, expected): diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py index ee76a9678..4428dade6 100644 --- a/api/tests/common/test_permissions.py +++ b/api/tests/common/test_permissions.py @@ -98,9 +98,10 @@ def test_privacylevel_permission_me( assert check is expected +# "me" expects true since the object can be private but share with followers @pytest.mark.parametrize( "privacy_level,expected", - [("me", False), ("followers", True), ("instance", False), ("everyone", True)], + [("me", True), ("followers", True), ("instance", False), ("everyone", True)], ) def test_privacylevel_permission_followers( factories, api_request, anonymous_user, privacy_level, expected, mocker diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index b996db837..ea9e24e57 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -35,6 +35,29 @@ def test_user_can_fetch_library_using_url(mocker, factories, logged_in_api_clien assert response.data["results"] == [api_serializers.LibrarySerializer(library).data] +def test_user_can_fetch_playlist_library_using_url( + mocker, factories, logged_in_api_client +): + pl_library = factories["music.Library"]() + upload = factories["music.Upload"]() + upload.playlist_libraries.add(pl_library) + + mocked_retrieve = mocker.patch( + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=pl_library + ) + url = reverse("api:v1:federation:libraries-fetch") + response = logged_in_api_client.post(url, {"fid": pl_library.fid}) + assert mocked_retrieve.call_count == 1 + args = mocked_retrieve.call_args + assert args[0] == (pl_library.fid,) + assert args[1]["queryset"].model == views.MusicLibraryViewSet.queryset.model + assert args[1]["serializer_class"] == serializers.LibrarySerializer + assert response.status_code == 200 + assert response.data["results"] == [ + api_serializers.LibrarySerializer(pl_library).data + ] + + def test_user_can_schedule_library_scan(mocker, factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() library = factories["music.Library"](privacy_level="everyone") diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index a455fed18..26d72ba85 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -1268,6 +1268,7 @@ def test_inbox_update_playlist(factories, mocker): playlist_data = serializers.PlaylistSerializer(playlist_updated).data playlist_data["id"] = str(playlist.fid) + playlist_updated.delete() routes.inbox_update_playlist( {"object": playlist_data}, diff --git a/api/tests/music/test_migrations.py b/api/tests/music/test_migrations.py index ae695b527..b0f2773a6 100644 --- a/api/tests/music/test_migrations.py +++ b/api/tests/music/test_migrations.py @@ -1,6 +1,7 @@ from uuid import uuid4 import pytest +from django.conf import settings from django.utils.timezone import now # this test is commented since it's very slow, but it can be useful for future development @@ -102,13 +103,13 @@ def test_migrate_libraries_to_playlist(migrator): Track = music_apps.get_model("music", "Track") Library = music_apps.get_model("music", "Library") Upload = music_apps.get_model("music", "Upload") + Playlist = music_apps.get_model("playlists", "Playlist") # Create data + d = settings.FEDERATION_HOSTNAME domain = Domain.objects.create() domain2 = Domain.objects.create(pk=2) - actor = Actor.objects.create(name="Test Actor", domain=domain) - existing_urls = Actor.objects.values_list("fid", flat=True) - print(existing_urls) + actor = Actor.objects.create(name="Test Actor", domain=domain, fid=f"http://{d}/") target_actor = Actor.objects.create( name="Test Actor 2", domain=domain2, @@ -117,12 +118,31 @@ def test_migrate_libraries_to_playlist(migrator): library = Library.objects.create( name="This should becane playlist name", - actor=target_actor, + actor=actor, creation_date=now(), privacy_level="everyone", uuid=uuid4(), description="This is a description", ) + Library.objects.create( + name="me", + actor=actor, + creation_date=now(), + privacy_level="me", + uuid=uuid4(), + description="This is a description", + fid=f"http://{d}/mylocallob", + ) + + library_not_local = Library.objects.create( + name="This should not becane playlist name", + fid="https://asupernotlocal.acab/federation/music/libraries/8505207e-45da-449a-9ec8-ed12a848fcea", + actor=target_actor, + creation_date=now(), + privacy_level="everyone", + uuid=uuid4(), + description="This is a description recalling to eat the rich", + ) Track.objects.create() Track.objects.create() @@ -136,6 +156,33 @@ def test_migrate_libraries_to_playlist(migrator): Upload.objects.create(library=library, track=track3), ] + Upload.objects.create(library=library_not_local, track=track), + Upload.objects.create(library=library_not_local, track=track2), + Upload.objects.create(library=library_not_local, track=track3), + # Plt = music_apps.get_model("playlists", "PlaylistTrack") + # playlist = Playlist.objects.create( + # name="This should becane a library name", + # fid="https://asupernotlocal.acab/federation/music/playlist/8505207e-45da-449a-9ec8-ed12a848fcea", + # actor=actor, + # creation_date=now(), + # privacy_level="everyone", + # uuid=uuid4(), + # ) + + # playlist_not_local = Playlist.objects.create( + # name="This should not becane a library name", + # actor=target_actor, + # creation_date=now(), + # privacy_level="everyone", + # uuid=uuid4(), + # ) + # Plt.objects.create(playlist=playlist, track=track) + # Plt.objects.create( + # playlist=playlist_not_local, + # track=track, + # fid="https://asupernotlocal.acab/federation/music/playlistttrack/8505207e-", + # ) + library_follow = LibraryFollow.objects.create( uuid=uuid4(), target=library, @@ -152,9 +199,8 @@ def test_migrate_libraries_to_playlist(migrator): new_apps = migrator.loader.project_state([music_final_migration]).apps Playlist = new_apps.get_model("playlists", "Playlist") PlaylistTrack = new_apps.get_model("playlists", "PlaylistTrack") - Follow = new_apps.get_model("federation", "Follow") LibraryFollow = new_apps.get_model("federation", "LibraryFollow") - Follow = new_apps.get_model("federation", "Follow") + Library = new_apps.get_model("music", "Library") # Assertions @@ -166,21 +212,43 @@ def test_migrate_libraries_to_playlist(migrator): assert playlist.privacy_level == library.privacy_level assert playlist.description == library.description + # Verify Playlist me creation skipped + assert not Playlist.objects.filter(name="me") + assert playlist.actor.libraries.filter(name="me").count() == 1 + # Verify PlaylistTrack creation playlist_tracks = PlaylistTrack.objects.filter(playlist=playlist).order_by("index") assert playlist_tracks.count() == 3 for i, playlist_track in enumerate(playlist_tracks): assert playlist_track.track.pk == uploads[i].track.pk - # Verify User Follow creation - follow = Follow.objects.get(target__pk=target_actor.pk) + # Verify playlist.library Follow creation + follow = LibraryFollow.objects.get(target__pk=playlist.library.pk) assert follow.actor.pk == actor.pk assert follow.approved == library_follow.approved + assert follow.target == playlist.library - # Verify LibraryFollow deletion and library creation - assert LibraryFollow.objects.count() == 0 + # Verify uploads are migrated in lib.playlist_uploads + for upload in uploads: + assert upload.pk in [u.pk for u in playlist.library.playlist_uploads.all()] + assert upload.pk not in [u.pk for u in playlist.library.uploads.all()] + assert not playlist.library.uploads.all() # Test fail but works on real db I don't get why # no library are found in the new app # NewAppLibrary = new_apps.get_model("music", "Library") # assert NewAppLibrary.objects.count() == 3 + + # Playlist + # library = Playlist.objects.get(name="This should becane a library name").library + # assert library.name == "This should becane a library name" + # assert library.privacy_level == "me" + + # # Not local + # library_not_local = Library.objects.get(fid=library_not_local.fid) + # assert not library_not_local.playlist_uploads.all() + + # playlist_not_local = Playlist.objects.get( + # name="This should not becane a library name" + # ) + # assert not playlist_not_local.library diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index dfef58a54..1c168bcce 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1572,7 +1572,7 @@ def test_can_patch_upload_list(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() upload = factories["music.Upload"](library__actor=actor) upload2 = factories["music.Upload"](library__actor=actor) - factories["music.Library"](actor=actor, privacy_level="everyone") + factories["music.Library"](actor=actor, privacy_level="everyone", name="everyone") response = logged_in_api_client.patch( url, @@ -1587,3 +1587,30 @@ def test_can_patch_upload_list(factories, logged_in_api_client): assert response.status_code == 200 assert upload.library.privacy_level == "everyone" + + +def test_upload_list_wont_use_playlist_lib(factories, logged_in_api_client): + url = reverse("api:v1:uploads-bulk-update") + actor = logged_in_api_client.user.create_actor() + upload = factories["music.Upload"](library__actor=actor) + upload2 = factories["music.Upload"](library__actor=actor) + playlist = factories["playlists.Playlist"]() + lib = factories["music.Library"]( + actor=actor, + privacy_level="everyone", + name="everyone", + ) + playlist.library = lib + playlist.save() + response = logged_in_api_client.patch( + url, + [ + {"uuid": upload.uuid, "privacy_level": "everyone"}, + {"uuid": upload2.uuid, "privacy_level": "everyone"}, + ], + format="json", + ) + upload.refresh_from_db() + upload2.refresh_from_db() + + assert response.status_code == 400 diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py index 8cbd9a02b..142d36172 100644 --- a/api/tests/playlists/test_models.py +++ b/api/tests/playlists/test_models.py @@ -286,3 +286,32 @@ def test_playlist_playable_by_anonymous(privacy_level, expected, factories): queryset = playlist.__class__.objects.playable_by(None).with_playable_plts(None) match = playlist in list(queryset) assert match is expected + + +def test_playlist_playable_by_library_playlist_follower(factories): + plt = factories["playlists.PlaylistTrack"]() + playlist = plt.playlist + playlist.privacy_level = "everyone" + playlist.save() + track = plt.track + upload = factories["music.Upload"]( + track=track, library__privacy_level="me", import_status="finished" + ) + upload.playlist_libraries.add(playlist.library) + follow = factories["federation.LibraryFollow"]( + target=playlist.library, approved=True + ) + + # skip actortrack denormalization + assert ( + plt.track.uploads.all() + .first() + .__class__.objects.playable_by(follow.actor) + .exists() + ) + + # doesn't skip actortrack denormalization so will fail need the library scan to be triggered + # queryset = playlist.__class__.objects.playable_by(follow.actor).with_playable_plts( + # None + # ) + # assert playlist in list(queryset) diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index e14f4710f..bd5ca4e31 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -75,7 +75,8 @@ def test_playlist_serializer(factories, to_api_date): actor = playlist.actor expected = { - "id": playlist.pk, + "uuid": playlist.uuid, + "fid": playlist.fid, "name": playlist.name, "privacy_level": playlist.privacy_level, "is_playable": False, @@ -86,6 +87,8 @@ def test_playlist_serializer(factories, to_api_date): "tracks_count": 0, "album_covers": [], "description": playlist.description, + "library": playlist.library.fid, + "library_followed": None, } serializer = serializers.PlaylistSerializer(playlist) diff --git a/api/tests/playlists/test_tasks.py b/api/tests/playlists/test_tasks.py index 4af75c766..1d43e6e0b 100644 --- a/api/tests/playlists/test_tasks.py +++ b/api/tests/playlists/test_tasks.py @@ -17,6 +17,8 @@ def test_scan_playlist_page_fetches_page_and_creates_tracks( for i in range(5) ] + for plt in tracks: + factories["music.Upload"](track=plt.track, library__actor=scan.playlist.actor) page_conf = { "actor": scan.playlist.actor, "id": scan.playlist.fid, @@ -35,7 +37,8 @@ def test_scan_playlist_page_fetches_page_and_creates_tracks( assert len(plts) == 3 for track in tracks[:3]: - scan.playlist.playlist_tracks.get(fid=track.fid) + plt = scan.playlist.playlist_tracks.get(fid=track.fid) + scan.playlist.library in plt.track.uploads.all()[0].playlist_libraries.all() assert scan.status == "scanning" assert scan.processed_files == 3 diff --git a/api/tests/playlists/test_urls_v2.py b/api/tests/playlists/test_urls_v2.py index 53355df0b..5647d6ba1 100644 --- a/api/tests/playlists/test_urls_v2.py +++ b/api/tests/playlists/test_urls_v2.py @@ -24,7 +24,7 @@ def test_can_get_playlists_octet_stream(factories, logged_in_api_client): factories["playlists.PlaylistTrack"](playlist=pl) factories["playlists.PlaylistTrack"](playlist=pl) - url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid}) headers = {"Accept": "application/octet-stream"} response = logged_in_api_client.get(url, headers=headers) el = etree.fromstring(response.content) @@ -36,7 +36,7 @@ def test_can_get_playlists_octet_stream(factories, logged_in_api_client): def test_can_get_playlists_json(factories, logged_in_api_client): logged_in_api_client.user.create_actor() pl = factories["playlists.Playlist"]() - url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid}) response = logged_in_api_client.get(url, format="json") assert response.status_code == 200 assert response.data["name"] == pl.name @@ -105,7 +105,7 @@ def test_can_patch_playlists_octet_stream(factories, logged_in_api_client): track = factories["music.Track"]( title="Opinel 12", artist_credit__artist=artist, album=album ) - url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid}) data = open("./tests/playlists/test.xspf", "rb").read() response = logged_in_api_client.patch(url, data=data, format="xspf") pl.refresh_from_db() @@ -118,7 +118,7 @@ def test_can_get_playlists_track(factories, logged_in_api_client): logged_in_api_client.user.create_actor() pl = factories["playlists.Playlist"]() plt = factories["playlists.PlaylistTrack"](playlist=pl) - url = reverse("api:v2:playlists-tracks", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-tracks", kwargs={"uuid": pl.uuid}) response = logged_in_api_client.get(url) data = json.loads(response.content.decode("utf-8")) assert response.status_code == 200 @@ -130,7 +130,7 @@ def test_can_get_playlists_releases(factories, logged_in_api_client): logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"]() plt = factories["playlists.PlaylistTrack"](playlist=playlist) - url = reverse("api:v2:playlists-albums", kwargs={"pk": playlist.pk}) + url = reverse("api:v2:playlists-albums", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.get(url) data = json.loads(response.content) assert response.status_code == 200 @@ -141,7 +141,7 @@ def test_can_get_playlists_artists(factories, logged_in_api_client): logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"]() plt = factories["playlists.PlaylistTrack"](playlist=playlist) - url = reverse("api:v2:playlists-artists", kwargs={"pk": playlist.pk}) + url = reverse("api:v2:playlists-artists", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.get(url) data = json.loads(response.content) assert response.status_code == 200 diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 30bfd27d8..ded58f453 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -21,7 +21,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"](actor=actor) factories["playlists.PlaylistTrack"](playlist=playlist) - url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.get(url, content_type="application/json") assert response.data["tracks_count"] == 1 @@ -34,7 +34,7 @@ def test_serializer_includes_tracks_count_986(factories, logged_in_api_client): factories["music.Upload"].create_batch( 3, track=plt.track, library__privacy_level="everyone", import_status="finished" ) - url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.get(url, content_type="application/json") assert response.data["tracks_count"] == 1 @@ -45,7 +45,7 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client): playlist = factories["playlists.Playlist"]() factories["playlists.PlaylistTrack"](playlist=playlist) - url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.get(url, content_type="application/json") assert response.data["is_playable"] is False @@ -81,7 +81,7 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli logged_in_api_client.user.create_actor() track = factories["music.Track"]() playlist = factories["playlists.Playlist"]() - url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid}) data = {"tracks": [track.pk]} response = logged_in_api_client.post(url, data, content_type="application/json") @@ -96,7 +96,7 @@ def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client): playlist = factories["playlists.Playlist"](actor=actor) plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist) plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist) - url = reverse("api:v1:playlists-remove", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-remove", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.delete(url, {"index": 0}) @@ -108,6 +108,40 @@ def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client): assert plt1.index == 0 +def test_deleting_plt_updates_pl_lib(mocker, factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + factories["music.Track"]() + playlist = factories["playlists.Playlist"](actor=actor) + # playlist.library.uploads.add(plt0.track.uploads.all()[0]) + # playlist.library.uploads.add(plt1.track.uploads.all()[0]) + + tracks = factories["music.Track"].create_batch(size=5) + for track in tracks: + factories["music.Upload"](track=track, library__actor=actor) + + not_user_actor = factories["federation.Actor"]() + not_user_upload = factories["music.Upload"]( + track=tracks[0], library__actor=not_user_actor + ) + + track_ids = [t.id for t in tracks] + url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid}) + logged_in_api_client.post(url, {"tracks": track_ids}) + + assert not_user_upload not in playlist.library.uploads.all() + for plt in playlist.playlist_tracks.all(): + for upload in playlist.library.uploads.all(): + assert upload.tracks.filter(id=plt.track.id).exists() + + url = reverse("api:v1:playlists-remove", kwargs={"uuid": playlist.uuid}) + logged_in_api_client.delete(url, {"index": 0}) + playlist.library.refresh_from_db() + + for plt in playlist.playlist_tracks.all(): + for upload in playlist.library.uploads.all(): + assert not upload.tracks.filter(id=plt.track.id).exists() + + @pytest.mark.parametrize("level", ["instance", "me", "followers"]) def test_playlist_privacy_respected_in_list_anon( preferences, level, factories, api_client @@ -124,7 +158,7 @@ def test_playlist_privacy_respected_in_list_anon( def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client): logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"]() - url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid}) response = getattr(logged_in_api_client, method.lower())(url) assert response.status_code == 404 @@ -138,7 +172,7 @@ def test_can_add_multiple_tracks_at_once_via_api( tracks = factories["music.Track"].create_batch(size=5) track_ids = [t.id for t in tracks] mocker.spy(playlist, "insert_many") - url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.post(url, {"tracks": track_ids}) assert response.status_code == 201 @@ -149,6 +183,43 @@ def test_can_add_multiple_tracks_at_once_via_api( assert plt.track == tracks[plt.index] +def test_add_multiple_tracks_at_once_update_pl_library( + factories, mocker, logged_in_api_client +): + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) + tracks = factories["music.Track"].create_batch(size=5) + not_user_actor = factories["federation.Actor"]() + not_user_track = factories["music.Track"]() + not_user_upload = factories["music.Upload"]( + size=5, track=not_user_track, library__actor=not_user_actor + ) + for track in tracks: + factories["music.Upload"](track=track, library__actor=actor) + + track_already_in_playlist = factories["music.Track"]() + upload_already_in_playlist = factories["music.Upload"]( + track=track_already_in_playlist, library__actor=actor + ) + upload_already_in_playlist.playlist_libraries.add(playlist.library) + + track_ids = [t.id for t in tracks] + track_ids.append(not_user_track.id) + track_ids.append(track_already_in_playlist.id) + mocker.spy(playlist, "insert_many") + url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid}) + response = logged_in_api_client.post(url, {"tracks": track_ids}) + + assert response.status_code == 201 + assert playlist.playlist_tracks.count() == len(track_ids) + assert playlist.playlist_tracks.filter(track=not_user_track).exists() + assert not_user_upload not in playlist.library.uploads.all() + for plt in playlist.playlist_tracks.all(): + for upload in playlist.library.uploads.all(): + assert upload.tracks.filter(id=plt.track.id).exists() + assert len(upload.playlist_libraries.all()) == 1 + + def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, preferences): actor = logged_in_api_client.user.create_actor() preferences["playlists__max_tracks"] = 3 @@ -158,7 +229,7 @@ def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, prefer ) track_ids = [t.id for t in tracks] mocker.spy(playlist, "insert_many") - url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.post(url, {"tracks": track_ids}) assert response.status_code == 400 @@ -168,18 +239,34 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client): actor = logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"](actor=actor) factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist) - url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-clear", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.delete(url) assert response.status_code == 204 assert playlist.playlist_tracks.count() == 0 +def test_clear_playlist_from_api_remove_pl_lib_uploads( + factories, mocker, logged_in_api_client +): + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) + factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist) + for upload in playlist.library.uploads.all(): + assert upload.playlist_libraries.filter(playlist=playlist).exists() + assert upload.playlist_libraries.get(playlist=playlist).actor == actor + url = reverse("api:v1:playlists-clear", kwargs={"uuid": playlist.uuid}) + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + assert not playlist.library.uploads.all() + + def test_update_playlist_from_api(factories, mocker, logged_in_api_client): actor = logged_in_api_client.user.create_actor() playlist = factories["playlists.Playlist"](actor=actor) factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist) - url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.patch(url, {"name": "test"}) playlist.refresh_from_db() @@ -192,7 +279,7 @@ def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client): playlist = factories["playlists.Playlist"](actor=actor) plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist) plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist) - url = reverse("api:v1:playlists-move", kwargs={"pk": playlist.pk}) + url = reverse("api:v1:playlists-move", kwargs={"uuid": playlist.uuid}) response = logged_in_api_client.post(url, {"from": 1, "to": 0}) diff --git a/changes/changelog.d/2417.feature b/changes/changelog.d/2417.feature new file mode 100644 index 000000000..88393f67d --- /dev/null +++ b/changes/changelog.d/2417.feature @@ -0,0 +1 @@ +Use playlists to privately share audio files (#2417) diff --git a/docs/developer/federation/privacy.md b/docs/developer/federation/privacy.md index 5e38c4224..9ff6dda2a 100644 --- a/docs/developer/federation/privacy.md +++ b/docs/developer/federation/privacy.md @@ -7,16 +7,18 @@ Two level of privacy for activities : - from the Actor of the activities - from the Object of the activities -We follow both actor and object privacy_level. If an user want to share it's playlist he need both the user privacy level and playlist privacy level set to allow it. - ### User level privacy_level -Check is done in `activity_pass_user_privacy_level` but only works if `actor` is passed within the `context` +Check is done in `activity_pass_user_privacy_level` but only works if `actor` is passed within the `context`. This applies to object that don't have their own privacy_level settings : + +- `Listening` +- `TrackFavorite` ### Object privacy_level -Playlist support it's own privacy level. Check is done in `activity_pass_object_privacy_level`. Other objects should be added manually to this function. +`Playlist` support it's own privacy level. Check is done in `activity_pass_object_privacy_level` for AP activities and on `has_playlist_access` for the federation views. Other objects should be added manually to this function. +`PlaylistTracks` follow the `Playlist` privacy_level setting. -## Followers privacy_level +## Followers privacy_level for User If a user follow a local user we don't need to send ActivityPub activities since the data is already in our db. We can use the local database the fetch the data. That's why Funkwhale outbox will always discard activities that are not public. But this need to be updated to support `followers` privacy level. Some warning should be displayed to the users to explain that setting a privacy_level to `followers` will send the data to remote server. This means we need to trust the remote server admins to follow our privacy_level wish. In other words when you trust your followers your also trust the admins of your followers. diff --git a/docs/specs/playlist-library-federation/index.md b/docs/specs/playlist-library-federation/index.md new file mode 100644 index 000000000..7953fdd4a --- /dev/null +++ b/docs/specs/playlist-library-federation/index.md @@ -0,0 +1,66 @@ +## Playlist libraries to share audio files + +### The Issue + +- As a user I want to share a list of tracks privately to my friends +- As a user I want to have a single container to curate my content (not playlist and libraries, only playlists) + +### Proposed Solution + +The users can request access to the playlist content to the playlist owner + +### Feature Behavior + +Users will be able to click on a "Request access to playlist audios files" button. This is a `LibraryFollow` request of the `playlist.library`. Not to be confused with the playlist follow request (see #-followup) + +#### Backend + +##### Data model + +`Playlist` one_to_one with `Library` through `library` field +`Upload` many_to_one with `Library` through `library` (reverse is `library.uploads`) +`Upload` has also a many_to_many with `Library` through `playlist_libraries` (the same upload can be share various time through various playlists). Reverse relation is `library.playlist_uploads` + +We could migrate from O2M to M2M, but this is super complicated since : - it adds a lot of extra logic (you can't query the m2m if the instance is not save -> this generated problem to validate incoming AP objects) - having a built-in lib and playlist libs make verifications easier (only three built-in lib, playlist_lib are always private) + +##### Workflow + +Playlist activity -> library_scan(get the uploads) -> playlist_scan (set the upload.playlist_relation and create plts) + +##### Federation + +Since `Playlist` is the main object here, we use the `Playlist` activities to send the `Library` information on ActivitiPub. +There is no other reason to share the playlit.library to remote. + +##### Migrations + +1. Remote library are not changed +2. Local lib are not deleted but are assigned to a playlist +3. Libraries Follows are not touched +4. Remote want fetch local libs as always but they will need to update the data or fail (migrating uploads from `library` to `playlist_library`) + +##### Done + +- [x] `PlaylistViewSet` `add` `clear` `remove` update the uploads.playlist_libraries relationships +- [x] `PlaylistViewSet` `add` `clear` `remove` -> `schedule_scan` -> Update activity to remote -> playlist.library scan on remote +- [x] library and playlist scan delay are long (24h), force on ap update +- [x] make sure only owned upload are added to the playlist.library +- [x] update the "drop library" migrations to use the playlist.library instead of user follow +- [x] make sure user get the new libraries created after library drop +- [x] update the federation api : when we receive a fetch for a library the upload serializer need to know which lib (playlist lib or user lib) +- [x] Support library.playlist_uploads in library scan -> add playlist_uploads in items in library federation viewset +- [x] investigate library scan bug : don't delete old content of the lib (local cache?): we need to empty the playlist before the scan(not ideal but less work) +- [x] check actor has only have three built-in libs and upload.playlist_libraries is private after migration +- [x] Playlist discovery : fetch federation endpoint for playlists +- [ ] Seem like the federation fetch (either with fetch endpoint or retreive_ap_obj) is deleting the `privacy_level` since `audience` can only be public or null. Avoid `privacy_level` to be updated if it a local playlist. + +### Follow up + +- [ ] Add the frontend playlist button in the new ui +- [ ] Playlist discovery : display playlist fid in the frontend +- [ ] Document : The user that want to federate need to activate remote activities in it's user settings. Even if the library is public the playlist activities will not be sended to remote -> We need to implement a followers activity setting (#2362) +- [ ] allow users to change the upload to another built-in lib, make sure the upload is not delete (we would loose the playlist_library relation) but only updated + +- [ ] Playlist discovery : add the playlist to my playlist collection = follow request to playlist +- [ ] Playlist Track activity (to avoid having to refetch the whole playlist) +- [ ] Add a popover button to force playlist scan ? or make playlist scan delay shorter ? diff --git a/front/src/components/audio/EmbedWizard.vue b/front/src/components/audio/EmbedWizard.vue index 3fb976f46..b6077cf23 100644 --- a/front/src/components/audio/EmbedWizard.vue +++ b/front/src/components/audio/EmbedWizard.vue @@ -12,7 +12,8 @@ import Spacer from '~/components/ui/Spacer.vue' interface Props { type: string - id: number + id?: number + uuid?: string } const { t } = useI18n() @@ -44,7 +45,8 @@ const iframeSrc = computed(() => { ? `&b=${instanceUrl}` : '' - return `${base}embed.html?&type=${props.type}&id=${props.id}${bParam}` + const identifier = props.id ?? props.uuid + return `${base}embed.html?&type=${props.type}&id=${identifier}${bParam}` }) const frameWidth = computed(() => width.value ?? '100%') diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 936ba1215..dfc8e4600 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -72,7 +72,8 @@ const { enqueue, enqueueNext, replacePlay, - isLoading + isLoading, + requestPlaylistUploadsAccess } = usePlayOptions(props) const { report, getReportableObjects } = useReport() @@ -98,10 +99,50 @@ const labels = computed(() => ({ ? t('components.audio.PlayButton.button.playArtist') : props.playlist ? t('components.audio.PlayButton.button.playPlaylist') - : t('components.audio.PlayButton.button.playTracks') -})) + : t('components.audio.PlayButton.button.playTracks'), + PlaylistUploadGranted: t('components.audio.PlayButton.button.PlaylistUploadGranted'), + PlaylistUploadPending:t('components.audio.PlayButton.button.PlaylistUploadPending'), + PlaylistUploadNotRequest: t('components.audio.PlayButton.button.PlaylistUploadNotRequest'), + PlaylistUploadTooltip: t('components.audio.PlayButton.button.PlaylistUploadTooltip') + })) const isOpen = ref(false) + +const playlistFollowInfo = computed(() => { + const playlist = props.playlist; + if (!playlist) return null; + + const followed = playlist.library_followed; + + if (followed === true) { + return { + label: labels.value.PlaylistUploadGranted, + tooltip: labels.value.PlaylistUploadTooltip, + icon: 'bi-check-circle', + disabled: true + }; + } + + if (followed === false) { + return { + label: labels.value.PlaylistUploadPending, + tooltip: labels.value.PlaylistUploadTooltip, + icon: 'bi-hourglass-split', + disabled: true + }; + } + + // Assume null/undefined means not yet requested + return { + label: labels.value.PlaylistUploadNotRequest, + tooltip: labels.value.PlaylistUploadTooltip, + icon: 'bi-eye-slash', + disabled: false, + action: requestPlaylistUploadsAccess + } +}); + + diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue index 31a729e63..988d83a36 100644 --- a/front/src/components/playlists/Widget.vue +++ b/front/src/components/playlists/Widget.vue @@ -100,7 +100,7 @@ watch( diff --git a/front/src/composables/audio/usePlayOptions.ts b/front/src/composables/audio/usePlayOptions.ts index ab9ead3a5..108571f33 100644 --- a/front/src/composables/audio/usePlayOptions.ts +++ b/front/src/composables/audio/usePlayOptions.ts @@ -129,7 +129,7 @@ export default (props: PlayOptionsProps) => { tracks.push(response.data as Track) } } else if (props.playlist) { - const response = await axios.get(`playlists/${props.playlist.id}/tracks/`) + const response = await axios.get(`playlists/${props.playlist.uuid}/tracks/`) const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track) const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id) @@ -200,6 +200,29 @@ export default (props: PlayOptionsProps) => { return replacePlay(index) } + const requestPlaylistUploadsAccess = async (playlist: Playlist) => { + const libraryUrl = playlist.library; + if (!libraryUrl) { + throw new Error("Playlist library URL is missing."); + } + const libResponse = await axios.get(libraryUrl); + const id = libResponse.data?.id || libResponse.data?.results?.id; + if (!id) { + throw new Error("Library id not found in response."); + } + const fetchResponse = await axios.post('federation/fetches', + { object: id } + ); + + const response = await axios.post( + 'federation/follows/library', + { target: fetchResponse.data.object.uuid } + ); + + return response; +}; + + return { playable, filterableArtist, @@ -208,6 +231,7 @@ export default (props: PlayOptionsProps) => { enqueueNext, replacePlay, activateTrack, - isLoading + isLoading, + requestPlaylistUploadsAccess } } diff --git a/front/src/composables/moderation/useReport.ts b/front/src/composables/moderation/useReport.ts index c29737101..c0a89de57 100644 --- a/front/src/composables/moderation/useReport.ts +++ b/front/src/composables/moderation/useReport.ts @@ -112,7 +112,7 @@ const getReportableObjects = ({ track, album, artist, artistCredit, playlist, ac label: t('composables.moderation.useReport.playlist.label'), target: { type: 'playlist', - id: playlist.id, + uuid: playlist.uuid, label: playlist.name, _obj: playlist, typeLabel: t('composables.moderation.useReport.playlist.typeLabel') diff --git a/front/src/generated/types.ts b/front/src/generated/types.ts index 4bd0b65c4..03e6c53af 100644 --- a/front/src/generated/types.ts +++ b/front/src/generated/types.ts @@ -2233,7 +2233,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/": { + "/api/v1/playlists/{uuid}/": { parameters: { query?: never; header?: never; @@ -2249,7 +2249,7 @@ export interface paths { patch: operations["partial_update_playlist"]; trace?: never; }; - "/api/v1/playlists/{id}/add/": { + "/api/v1/playlists/{uuid}/add/": { parameters: { query?: never; header?: never; @@ -2265,7 +2265,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/albums/": { + "/api/v1/playlists/{uuid}/albums/": { parameters: { query?: never; header?: never; @@ -2281,14 +2281,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/artists/": { + "/api/v1/playlists/{uuid}/artists/": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_playlist_artits"]; + get: operations["get_playlist_artists"]; put?: never; post?: never; delete?: never; @@ -2297,7 +2297,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/clear/": { + "/api/v1/playlists/{uuid}/clear/": { parameters: { query?: never; header?: never; @@ -2313,7 +2313,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/move/": { + "/api/v1/playlists/{uuid}/move/": { parameters: { query?: never; header?: never; @@ -2329,7 +2329,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/remove/": { + "/api/v1/playlists/{uuid}/remove/": { parameters: { query?: never; header?: never; @@ -2345,7 +2345,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/playlists/{id}/tracks/": { + "/api/v1/playlists/{uuid}/tracks/": { parameters: { query?: never; header?: never; @@ -5226,7 +5226,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/": { + "/api/v2/playlists/{uuid}/": { parameters: { query?: never; header?: never; @@ -5242,7 +5242,7 @@ export interface paths { patch: operations["partial_update_playlist_2"]; trace?: never; }; - "/api/v2/playlists/{id}/add/": { + "/api/v2/playlists/{uuid}/add/": { parameters: { query?: never; header?: never; @@ -5258,7 +5258,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/albums/": { + "/api/v2/playlists/{uuid}/albums/": { parameters: { query?: never; header?: never; @@ -5274,14 +5274,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/artists/": { + "/api/v2/playlists/{uuid}/artists/": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_playlist_artits_2"]; + get: operations["get_playlist_artists_2"]; put?: never; post?: never; delete?: never; @@ -5290,7 +5290,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/clear/": { + "/api/v2/playlists/{uuid}/clear/": { parameters: { query?: never; header?: never; @@ -5306,7 +5306,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/move/": { + "/api/v2/playlists/{uuid}/move/": { parameters: { query?: never; header?: never; @@ -5322,7 +5322,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/remove/": { + "/api/v2/playlists/{uuid}/remove/": { parameters: { query?: never; header?: never; @@ -5338,7 +5338,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/playlists/{id}/tracks/": { + "/api/v2/playlists/{uuid}/tracks/": { parameters: { query?: never; header?: never; @@ -8497,7 +8497,10 @@ export interface components { summary?: components["schemas"]["ContentRequest"] | null; }; Playlist: { - readonly id: number; + /** Format: uuid */ + readonly uuid: string; + /** Format: uri */ + readonly fid: string; name: string; readonly actor: components["schemas"]["APIActor"]; /** Format: date-time */ @@ -8510,6 +8513,9 @@ export interface components { readonly duration: number; readonly is_playable: boolean; description?: string | null; + /** Format: uri */ + readonly library: string; + readonly library_followed: boolean; }; PlaylistAddManyRequest: { tracks: number[]; @@ -14142,8 +14148,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14167,8 +14172,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14199,8 +14203,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14222,8 +14225,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14254,8 +14256,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14286,8 +14287,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14304,15 +14304,14 @@ export interface operations { }; }; }; - get_playlist_artits: { + get_playlist_artists: { parameters: { query?: { format?: "json" | "xspf"; }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14336,8 +14335,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14359,8 +14357,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14391,8 +14388,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14423,8 +14419,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -14460,8 +14455,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21155,8 +21149,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21180,8 +21173,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21212,8 +21204,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21235,8 +21226,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21267,8 +21257,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21299,8 +21288,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21317,15 +21305,14 @@ export interface operations { }; }; }; - get_playlist_artits_2: { + get_playlist_artists_2: { parameters: { query?: { format?: "json" | "xspf"; }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21349,8 +21336,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21372,8 +21358,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21404,8 +21389,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21436,8 +21420,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; @@ -21473,8 +21456,7 @@ export interface operations { }; header?: never; path: { - /** @description A unique integer value identifying this playlist. */ - id: number; + uuid: string; }; cookie?: never; }; diff --git a/front/src/locales/en_US.json b/front/src/locales/en_US.json index b90bbb0aa..4a4232dcd 100644 --- a/front/src/locales/en_US.json +++ b/front/src/locales/en_US.json @@ -483,6 +483,10 @@ "playArtist": "Play artist", "playNext": "Play next", "playNow": "Play now", + "PlaylistUploadGranted": "The owner of the playlist granted you access to his playlist's files", + "PlaylistUploadTooltip": "This only applies to files owned by the playlist actor. If you want to get access to all the files, think to buy them to support your favorites artists", + "PlaylistUploadPending": "You've already requested access to the playlist files", + "PlaylistUploadNotRequest": "Request access to the files from this playlist", "playPlaylist": "Play playlist", "playTrack": "Play track", "playTracks": "Play tracks", @@ -3216,7 +3220,7 @@ }, "useErrorHandler": { "errorReportMessage": "To help us understand why it happened, please attach a detailed description of what you did that has triggered the error.", - "errorReportTitle": "An unexpected error occured.", + "errorReportTitle": "An unexpected error occurred.", "leaveFeedback": "Leave feedback", "unexpectedError": "An unexpected error occurred." }, @@ -4279,7 +4283,7 @@ "showStatus": "Show information about the upload status for this track" }, "empty": { - "noTracks": "No tracks have been added to this libray yet" + "noTracks": "No tracks have been added to this library yet" }, "label": { "importStatus": "Import status", diff --git a/front/src/ui/modals/Search.vue b/front/src/ui/modals/Search.vue index 1fbc4d95e..38c49e767 100644 --- a/front/src/ui/modals/Search.vue +++ b/front/src/ui/modals/Search.vue @@ -413,9 +413,9 @@ const radioConfig = computed(() => }) : count({ type: 'playlists' }) > 0 ? ({ - type: 'playlist', - ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString()) - }) + type: 'playlist', + ids: resultsPerCategory({ type: 'playlists' }).map(({ uuid }) => uuid.toString()) + }) : count({ type: 'artists' }) > 0 ? ({ type: 'artist', diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 03f043fdb..aa3943a5c 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -44,16 +44,12 @@ const store = useStore() const edit = ref(props.defaultEdit) const playlist = ref(null) const playlistTracks = ref([]) - const showEmbedModal = ref(false) -// TODO: Compute `tracks` with `track`. In the new types, `track` is just a string. -// We probably have to load each track before loading it into `tracks`. -// Original line: const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ({ ...track, position: index + 1 }))) -const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ( - // @i-would-expect-ts-to-error because this typecasting is evil - { position: index + 1 } as Track -))) +type FullPlaylistTrack = Omit & { track: Track } +const fullPlaylistTracks = ref([]) + +const tracks = computed(() => fullPlaylistTracks.value.map(({ track }, index) => ({ ...track as Track, position: index + 1 }))) const { t } = useI18n() const labels = computed(() => ({ @@ -71,7 +67,7 @@ const fetchData = async () => { ]) playlist.value = playlistResponse.data - playlistTracks.value = tracksResponse.data.results + fullPlaylistTracks.value = tracksResponse.data.results } catch (error) { useErrorHandler(error as Error) } @@ -277,7 +273,7 @@ const shuffle = () => {}
diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 39f8ffef6..af8f0b11f 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -254,7 +254,7 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value] />