diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index b6500e741..ea70678ad 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -106,9 +106,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.user.actor.get_approved_followers(): return True else: return False diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 441688fad..b41da63d9 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -807,18 +807,18 @@ 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 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) + # we update the playlist.library + if follows := playlist.library.received_follows.filter(approved=True): + playlist.library.schedule_scan(follows[0].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..a2abb227d 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1037,7 +1037,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) @@ -1670,8 +1674,8 @@ 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") + if lb.fid != v and not lb.playlist: + raise serializers.ValidationError("Invalid library fid") return lb actor = self.context.get("actor") @@ -1683,10 +1687,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): @@ -2325,7 +2329,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 +2338,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, @@ -2364,6 +2372,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 +2386,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 +2398,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,11 +2416,20 @@ 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,6 +2440,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer): ), }, ) + return playlist def validate(self, data): 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..eadf870a1 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -383,13 +383,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( @@ -734,3 +737,22 @@ class PlaylistViewSet( querystring=request.GET, collection_serializer=serializers.PlaylistCollectionSerializer(playlist), ) + + +class PlaylistTrackViewSet( + FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + authentication_classes = [authentication.SignatureAuthentication] + permission_classes = [common_permissions.PrivacyLevelPermission] + 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 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/migrations/0061_migrate_libraries_to_playlist.py b/api/funkwhale_api/music/migrations/0061_migrate_libraries_to_playlist.py index 5c857ddcd..184754fe3 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 @@ -19,7 +19,7 @@ def insert_tracks_to_playlist(apps, playlist, uploads): uuid=(new_uuid := uuid.uuid4()), fid=federation_utils.full_url( reverse( - f"federation:music:playlists-detail", + f"federation:music:playlist-tracks-detail", kwargs={"uuid": new_uuid}, ) ), diff --git a/api/funkwhale_api/music/migrations/0064_upload_library_playlist.py b/api/funkwhale_api/music/migrations/0064_upload_library_playlist.py new file mode 100644 index 000000000..43d321e47 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0064_upload_library_playlist.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-09 21:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("music", "0063_upload_third_party_provider"), + ] + + operations = [ + migrations.AddField( + model_name="upload", + name="playlist_libraries", + field=models.ManyToManyField( + blank=True, + null=True, + related_name="playlist_uploads", + to="music.library", + ), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fe1e2b0c9..4e6a07c1b 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -744,13 +744,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): @@ -826,6 +827,12 @@ class Upload(models.Model): related_name="uploads", on_delete=models.CASCADE, ) + playlist_libraries = models.ManyToManyField( + "library", + null=True, + 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 c01585759..b79ec146e 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -549,9 +549,8 @@ class UploadBulkUpdateSerializer(serializers.Serializer): raise serializers.ValidationError( f"Upload with uuid {data['uuid']} does not exist" ) - upload.library = upload.library.actor.libraries.get( - privacy_level=data["privacy_level"] + privacy_level=data["privacy_level"], name=data["privacy_level"] ) 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..641f3c643 100644 --- a/api/funkwhale_api/playlists/factories.py +++ b/api/funkwhale_api/playlists/factories.py @@ -13,6 +13,9 @@ class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): actor = factory.SubFactory(ActorFactory) fid = factory.Faker("federation_url") uuid = factory.Faker("uuid4") + library = factory.SubFactory( + "funkwhale_api.federation.factories.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..32d8abe11 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0009_playlist_library.py @@ -0,0 +1,58 @@ +import django.db.models.deletion +from django.db import migrations, models + + +# to do : test migration +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) + + +def create_playlist_libraries(apps, schema_editor): + Playlist = apps.get_model("playlists", "Playlist") + Library = apps.get_model("music", "Library") + + for playlist in Playlist.objects.all(): + library = playlist.library + if library is None: + library = Library.objects.create( + name=playlist.name, privacy_level="me", actor=playlist.actor + ) + library.save() + playlist.library = library + playlist.save() + add_uploads_to_pl_library(playlist, library) + + +class Migration(migrations.Migration): + dependencies = [ + ("music", "0063_upload_third_party_provider"), + ("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 + ), + migrations.AlterField( + model_name="playlist", + name="library", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="playlist", + to="music.library", + ), + ), + ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index d70d0b2a3..f7aa49cad 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -88,6 +88,11 @@ 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", + on_delete=models.CASCADE, + related_name="playlist", + ) def __str__(self): return self.name @@ -109,6 +114,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 @@ -345,6 +363,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/serializers.py b/api/funkwhale_api/playlists/serializers.py index 7494c2838..e93957780 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -34,6 +34,7 @@ class PlaylistSerializer(serializers.ModelSerializer): album_covers = serializers.SerializerMethodField(read_only=True) is_playable = serializers.SerializerMethodField() actor = APIActorSerializer(read_only=True) + library = serializers.SerializerMethodField() class Meta: model = models.Playlist @@ -48,10 +49,14 @@ class PlaylistSerializer(serializers.ModelSerializer): "album_covers", "duration", "is_playable", - "actor", + "library", ) read_only_fields = ["id", "modification_date", "creation_date"] + @extend_schema_field(OpenApiTypes.UUID) + def get_library(self, obj): + return obj.library.fid + @extend_schema_field(OpenApiTypes.BOOL) def get_is_playable(self, obj): return getattr(obj, "is_playable_by_actor", False) diff --git a/api/funkwhale_api/playlists/tasks.py b/api/funkwhale_api/playlists/tasks.py index fd6d51f2d..24a7b2b5d 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()) @@ -59,7 +67,6 @@ def get_playlist_page(playlist, page_url, actor): "playlist_scan", ) 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: @@ -92,9 +99,14 @@ def scan_playlist_page(playlist_scan, page_url): data = get_playlist_page(playlist_scan.playlist, page_url, playlist_scan.actor) tracks = [] 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: + track = item_serializer.save(playlist=playlist_scan.playlist.fid) + tracks.append(track) + 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.modification_date = timezone.now() diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 98872f601..19a2d4e90 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 @@ -157,6 +158,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,6 +169,7 @@ class PlaylistViewSet( playlist = self.get_object() playlist.playlist_tracks.all().delete() playlist.save(update_fields=["modification_date"]) + playlist.library.uploads.filter().delete() playlist.schedule_scan(playlist.actor) return Response(status=204) @@ -200,6 +203,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) @@ -258,3 +263,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/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 9e9b8fb10..d0cd5580e 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_views.py b/api/tests/music/test_views.py index dfef58a54..a8f714d2f 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, 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 a79690c6c..3c365f59d 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -85,6 +85,7 @@ def test_playlist_serializer(factories, to_api_date): "duration": 0, "tracks_count": 0, "album_covers": [], + "library": playlist.library.fid, } serializer = serializers.PlaylistSerializer(playlist) diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 30bfd27d8..de314b494 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -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={"pk": playlist.pk}) + 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={"pk": playlist.pk}) + 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 @@ -149,6 +183,35 @@ 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_ids = [t.id for t in tracks] + track_ids.append(not_user_track.id) + mocker.spy(playlist, "insert_many") + url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) + 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(): + upload.tracks.filter(id=plt.track.id).exists() + + 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 @@ -175,6 +238,22 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client): 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={"pk": playlist.pk}) + 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) 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/specs/playlist-library-federation/index.md b/docs/specs/playlist-library-federation/index.md new file mode 100644 index 000000000..78bef8ced --- /dev/null +++ b/docs/specs/playlist-library-federation/index.md @@ -0,0 +1,28 @@ +## Playlist Import export + +### The Issue + +- Has a user I want to share a list of tracks privately to mmy friends +- Has 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 + +- [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 +- [ ] make sure only owned upload are added to the playlist.library +- [ ] update the "drop library" migrations to use the playlist.library instead of user follow +- [ ] make sure user get the new libraries created after library drop + +### Follow up + +- [ ] Playlist discovery : fetch federation endpoint for playlists +- [ ] Playlist discovery : add the playlist to my playlist collection = follow request to playlist