diff --git a/api/config/urls/api_v2.py b/api/config/urls/api_v2.py index ff94ff4b8..1a2c05625 100644 --- a/api/config/urls/api_v2.py +++ b/api/config/urls/api_v2.py @@ -17,12 +17,6 @@ v2_patterns += [ r"^radios/", include(("funkwhale_api.radios.urls_v2", "radios"), namespace="radios"), ), - re_path( - r"^playlists/", - include( - ("funkwhale_api.playlists.urls_v2", "playlists"), namespace="playlists" - ), - ), ] v2_paths = { diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 4297219ce..52bf1c455 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -7,7 +7,6 @@ from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerial from . import models -# to do : to deprecate ? this is only a local activity, the federated activities serializers are in `/federation` class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() object = TrackActivitySerializer(source="track") diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 002c2456a..dc4bd9052 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -299,8 +299,8 @@ def schedule_key_rotation(actor_id, delay): tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay) -def activity_pass_privacy_level(context, routing): - TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like"] +def activity_pass_user_privacy_level(context, routing): + TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like", "Create"] TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"] MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"] @@ -308,6 +308,16 @@ def activity_pass_privacy_level(context, routing): type = routing.get("type", False) object_type = routing.get("object", {}).get("type", None) + if not actor: + logger.warning( + "No actor provided in activity context : \ + we cannot follow actor.privacy_level, activity will be sent by default." + ) + + # We do not consider music metadata has private + if object_type in MUSIC_OBJECT_TYPE: + return True + if type: if type in TYPE_IGNORE_USER_PRIVACY_LEVEL: return True @@ -317,12 +327,28 @@ def activity_pass_privacy_level(context, routing): "instance", ]: return False + return True + return True + + +def activity_pass_object_privacy_level(context, routing): + MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"] + + # we only support playlist federation for now + object = context.get("playlist", False) + + obj_privacy_level = object.privacy_level if object else None + object_type = routing.get("object", {}).get("type", None) + # We do not consider music metadata has private if object_type in MUSIC_OBJECT_TYPE: return True + if object and obj_privacy_level and obj_privacy_level in ["me", "instance"]: + return False + return True @@ -348,8 +374,16 @@ class OutboxRouter(Router): ) ) - if not activity_pass_privacy_level(context, routing): - logger.info("[federation] Discarding outbox dispatch due to privacy_level") + if activity_pass_user_privacy_level(context, routing) is False: + logger.info( + "[federation] Discarding outbox dispatch due to user privacy_level" + ) + return + + if activity_pass_object_privacy_level(context, routing) is False: + logger.info( + "[federation] Discarding outbox dispatch due to object privacy_level" + ) return for route, handler in self.routes: @@ -435,6 +469,7 @@ class OutboxRouter(Router): ) for a in activities: + logger.info(f"[federation] OUtbox sending activity : {a.pk}") funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk) return activities diff --git a/api/funkwhale_api/federation/contexts.py b/api/funkwhale_api/federation/contexts.py index 8c9f4b67b..5ea7ae4d4 100644 --- a/api/funkwhale_api/federation/contexts.py +++ b/api/funkwhale_api/federation/contexts.py @@ -295,6 +295,8 @@ CONTEXTS = [ "Artist": "fw:Artist", "ArtistCredit": "fw:ArtistCredit", "Library": "fw:Library", + "Playlist": "fw:Playlist", + "PlaylistTrack": "fw:PlaylistTrack", "bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"}, "size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"}, "position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"}, @@ -319,6 +321,7 @@ CONTEXTS = [ "copyright": "fw:copyright", "category": "schema:category", "language": "schema:inLanguage", + "playlist": {"@id": "fw:playlist", "@type": "@id"}, } }, }, diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py index 1b16e9f8c..2a8b22751 100644 --- a/api/funkwhale_api/federation/routes.py +++ b/api/funkwhale_api/federation/routes.py @@ -6,6 +6,7 @@ from django.db.models import Q from funkwhale_api.favorites import models as favorites_models from funkwhale_api.history import models as history_models from funkwhale_api.music import models as music_models +from funkwhale_api.playlists import models as playlist_models from . import activity, actors, models, serializers @@ -678,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"] @@ -740,3 +738,104 @@ def inbox_delete_listening(payload, context): logger.debug("Discarding deletion of unkwnown listening %s", listening_id) return favorite.delete() + + +@outbox.register({"type": "Create", "object.type": "Playlist"}) +def outbox_create_playlist(context): + playlist = context["playlist"] + + serializer = serializers.ActivitySerializer( + { + "type": "Create", + "actor": playlist.actor, + "id": playlist.fid, + "object": serializers.PlaylistSerializer(playlist).data, + } + ) + yield { + "type": "Create", + "actor": playlist.actor, + "payload": with_recipients( + serializer.data, + to=[{"type": "followers", "target": playlist.actor}], + ), + } + + +@outbox.register({"type": "Delete", "object.type": "Playlist"}) +def outbox_delete_playlist(context): + playlist = context["playlist"] + actor = playlist.actor + serializer = serializers.ActivitySerializer( + {"type": "Delete", "object": {"type": "Playlist", "id": playlist.fid}} + ) + yield { + "type": "Delete", + "actor": actor, + "payload": with_recipients( + serializer.data, + to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}], + ), + } + + +@inbox.register({"type": "Create", "object.type": "Playlist"}) +def inbox_create_playlist(payload, context): + serializer = serializers.PlaylistSerializer(data=payload["object"]) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + return {"object": instance} + + +@inbox.register({"type": "Delete", "object.type": "Playlist"}) +def inbox_delete_playlist(payload, context): + actor = context["actor"] + playlist_id = payload["object"].get("id") + + query = Q(fid=playlist_id) & Q(actor=actor) + try: + playlist = playlist_models.Playlist.objects.get(query) + except playlist_models.Playlist.DoesNotExist: + logger.debug("Discarding deletion of unkwnown listening %s", playlist_id) + return + playlist.playlist_tracks.all().delete() + playlist.delete() + + +@inbox.register({"type": "Update", "object.type": "Playlist"}) +def inbox_update_playlist(payload, context): + actor = context["actor"] + playlist_id = payload["object"].get("id") + + if not actor.playlists.filter(fid=playlist_id).exists(): + logger.debug("Discarding update of unkwnown playlist_id %s", playlist_id) + return + + 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()) + return + else: + logger.debug( + "Discarding update of playlist_id %s because of payload errors: %s", + playlist_id, + serializer.errors, + ) + + +@outbox.register({"type": "Update", "object.type": "Playlist"}) +def outbox_update_playlist(context): + playlist = context["playlist"] + serializer = serializers.ActivitySerializer( + {"type": "Update", "object": serializers.PlaylistSerializer(playlist).data} + ) + yield { + "type": "Update", + "actor": playlist.actor, + "payload": with_recipients( + serializer.data, + to=[{"type": "followers", "target": playlist.actor}], + ), + } diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 054ae6044..9ce48f70e 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -22,6 +22,7 @@ from funkwhale_api.moderation import signals as moderation_signals from funkwhale_api.music import licenses from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks +from funkwhale_api.playlists import models as playlists_models from funkwhale_api.tags import models as tags_models logger = logging.getLogger(__name__) @@ -972,7 +973,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): first = common_utils.set_query_parameter(conf["id"], page=1) current = first last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages) - d = { + data = { "id": conf["id"], "attributedTo": conf["actor"].fid, "totalItems": paginator.count, @@ -981,10 +982,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): "first": first, "last": last, } - d.update(get_additional_fields(conf)) + data.update(get_additional_fields(conf)) if self.context.get("include_ap_context", True): - d["@context"] = jsonld.get_default_context() - return d + data["@context"] = jsonld.get_default_context() + return data class LibrarySerializer(PaginatedCollectionSerializer): @@ -2241,3 +2242,178 @@ class ListeningSerializer(jsonld.JsonLdSerializer): actor=actor, track=track, ) + + +class PlaylistTrackSerializer(jsonld.JsonLdSerializer): + type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack]) + id = serializers.URLField(max_length=500) + track = serializers.URLField(max_length=500) + index = serializers.IntegerField() + creation_date = serializers.DateTimeField() + playlist = serializers.URLField(max_length=500, required=False) + + class Meta: + model = playlists_models.PlaylistTrack + jsonld_mapping = { + "track": jsonld.first_id(contexts.FW.track), + "playlist": jsonld.first_id(contexts.FW.playlist), + "index": jsonld.first_val(contexts.FW.index), + "creation_date": jsonld.first_val(contexts.AS.published), + } + + def to_representation(self, plt): + payload = { + "type": "PlaylistTrack", + "id": plt.fid, + "track": plt.track.fid, + "index": plt.index, + "attributedTo": plt.playlist.actor.fid, + "published": plt.creation_date.isoformat(), + } + if self.context.get("include_ap_context", True): + payload["@context"] = jsonld.get_default_context() + + if self.context.get("include_playlist", True): + payload["playlist"] = plt.playlist.fid + return payload + + def create(self, validated_data): + track = utils.retrieve_ap_object( + validated_data["track"], + actor=self.context.get("fetch_actor"), + queryset=music_models.Track, + serializer_class=TrackSerializer, + ) + playlist = utils.retrieve_ap_object( + validated_data["playlist"], + actor=self.context.get("fetch_actor"), + queryset=playlists_models.Playlist, + serializer_class=PlaylistTrackSerializer, + ) + + defaults = { + "track": track, + "index": validated_data["index"], + "creation_date": validated_data["creation_date"], + "playlist": playlist, + } + + plt, created = playlists_models.PlaylistTrack.objects.update_or_create( + defaults, + **{ + "uuid": validated_data["id"].rstrip("/").split("/")[-1], + "fid": validated_data["id"], + }, + ) + + return plt + + +class PlaylistSerializer(jsonld.JsonLdSerializer): + """ + Used for playlist activities + """ + + type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create]) + id = serializers.URLField(max_length=500) + uuid = serializers.UUIDField(required=False) + name = serializers.CharField(required=False) + attributedTo = serializers.URLField(max_length=500, required=False) + published = serializers.DateTimeField(required=False) + updated = serializers.DateTimeField(required=False) + audience = serializers.ChoiceField( + choices=[None, "https://www.w3.org/ns/activitystreams#Public"], + required=False, + allow_null=True, + allow_blank=True, + ) + updateable_fields = [ + ("name", "title"), + ("attributedTo", "attributed_to"), + ] + + class Meta: + model = playlists_models.Playlist + jsonld_mapping = common_utils.concat_dicts( + MUSIC_ENTITY_JSONLD_MAPPING, + { + "updated": jsonld.first_val(contexts.AS.published), + "audience": jsonld.first_id(contexts.AS.audience), + "attributedTo": jsonld.first_id(contexts.AS.attributedTo), + }, + ) + + def to_representation(self, playlist): + payload = { + "type": "Playlist", + "id": playlist.fid, + "name": playlist.name, + "attributedTo": playlist.actor.fid, + "published": playlist.creation_date.isoformat(), + "audience": playlist.privacy_level, + } + payload["audience"] = ( + contexts.AS.Public if playlist.privacy_level == "everyone" else "" + ) + if playlist.modification_date: + payload["updated"] = playlist.modification_date.isoformat() + if self.context.get("include_ap_context", True): + payload["@context"] = jsonld.get_default_context() + return payload + + def create(self, validated_data): + actor = utils.retrieve_ap_object( + validated_data["attributedTo"], + actor=self.context.get("fetch_actor"), + queryset=models.Actor, + serializer_class=ActorSerializer, + ) + ap_to_fw_data = { + "actor": actor, + "name": validated_data["name"], + "creation_date": validated_data["published"], + "privacy_level": validated_data["audience"], + } + playlist, created = playlists_models.Playlist.objects.update_or_create( + defaults=ap_to_fw_data, + **{ + "fid": validated_data["id"], + "uuid": validated_data.get( + "uuid", validated_data["id"].rstrip("/").split("/")[-1] + ), + }, + ) + return playlist + + def validate(self, data): + validated_data = super().validate(data) + if validated_data["audience"] not in [ + "https://www.w3.org/ns/activitystreams#Public", + "everyone", + ]: + raise serializers.ValidationError("Privacy_level must be everyone") + + validated_data["audience"] = "everyone" + return validated_data + + +class PlaylistCollectionSerializer(PaginatedCollectionSerializer): + """ + Used for the federation view. + """ + + type = serializers.ChoiceField(choices=[contexts.FW.Playlist]) + + def to_representation(self, playlist): + conf = { + "id": playlist.fid, + "name": playlist.name, + "page_size": 100, + "actor": playlist.actor, + "items": playlist.playlist_tracks.order_by("index").prefetch_related( + "tracks", + ), + "type": "Playlist", + } + r = super().to_representation(conf) + return r diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 46e990685..df08b5f38 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -5,6 +5,7 @@ import os import requests from django.conf import settings +from django.core.cache import cache from django.db import transaction from django.db.models import F, Q from django.db.models.deletion import Collector @@ -18,6 +19,7 @@ from funkwhale_api.common import preferences, session from funkwhale_api.common import utils as common_utils from funkwhale_api.moderation import mrf from funkwhale_api.music import models as music_models +from funkwhale_api.playlists import models as playlists_models from funkwhale_api.taskapp import celery from . import ( @@ -665,3 +667,14 @@ def check_single_remote_instance_availability(domain): domain.reachable = False domain.save() return domain.reachable + + +@celery.app.task(name="federation.trigger_playlist_ap_update") +def trigger_playlist_ap_update(playlist): + for playlist_uuid in cache.get("playlists_for_ap_update"): + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Playlist"}}, + context={ + "playlist": playlists_models.Playlist.objects.get(uuid=playlist_uuid) + }, + ) diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index bb4483486..78f321864 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -22,7 +22,7 @@ music_router.register(r"albums", views.MusicAlbumViewSet, "albums") 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") 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 3989b9cb8..d02995e76 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -16,6 +16,7 @@ from funkwhale_api.history import models as history_models from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils +from funkwhale_api.playlists import models as playlists_models from . import ( activity, @@ -703,3 +704,34 @@ class ListeningsViewSet( serializer = self.get_serializer(instance) return response.Response(serializer.data) + + +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 + lookup_field = "uuid" + + def retrieve(self, request, *args, **kwargs): + playlist = self.get_object() + if utils.should_redirect_ap_to_html(request.headers.get("accept")): + return redirect_to_html(playlist.get_absolute_url()) + + conf = { + "id": playlist.fid, + "actor": playlist.actor, + "name": playlist.name, + "items": playlist.playlist_tracks.order_by("index").prefetch_related( + "track", + ), + "item_serializer": serializers.PlaylistTrackSerializer, + } + return get_collection_response( + conf=conf, + querystring=request.GET, + collection_serializer=serializers.PlaylistCollectionSerializer(playlist), + ) diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index dc2684144..9154d102a 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -235,7 +235,7 @@ def get_target_owner(target): music_models.Album: lambda t: t.attributed_to, music_models.Track: lambda t: t.attributed_to, music_models.Library: lambda t: t.actor, - playlists_models.Playlist: lambda t: t.user.actor, + playlists_models.Playlist: lambda t: t.actor, federation_models.Actor: lambda t: t, } diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py index b9ba05038..9d959a7a9 100644 --- a/api/funkwhale_api/music/fake_data.py +++ b/api/funkwhale_api/music/fake_data.py @@ -9,6 +9,7 @@ from funkwhale_api.cli import users from funkwhale_api.federation import factories as federation_factories from funkwhale_api.history import factories as history_factories from funkwhale_api.music import factories as music_factories +from funkwhale_api.playlists import factories as playlist_factories from funkwhale_api.users import serializers logger = logging.getLogger(__name__) @@ -59,6 +60,15 @@ def create_data(count=2, super_user_name=None): ) print("Created fid", upload.track.fid) + playlist = playlist_factories.PlaylistFactory( + name="playlist test public", + privacy_level="everyone", + actor=( + super_user.actor if super_user else federation_factories.ActorFactory() + ), + ) + playlist_factories.PlaylistTrackFactory(playlist=playlist, track=upload.track) + if __name__ == "__main__": create_data() diff --git a/api/funkwhale_api/music/management/commands/create_playlist_from_folder_structure.py b/api/funkwhale_api/music/management/commands/create_playlist_from_folder_structure.py index 1800bb384..ac24e0047 100644 --- a/api/funkwhale_api/music/management/commands/create_playlist_from_folder_structure.py +++ b/api/funkwhale_api/music/management/commands/create_playlist_from_folder_structure.py @@ -12,12 +12,14 @@ from funkwhale_api.users import models as user_models def get_or_create_playlist(self, playlist_name, user, **options): playlist = playlist_models.Playlist.objects.filter( - Q(user=user) & Q(name=playlist_name) + Q(actor=user.actor) & Q(name=playlist_name) ).first() if not playlist: if options["no_dry_run"]: playlist = playlist_models.Playlist.objects.create( - name=playlist_name, user=user, privacy_level=options["privacy_level"] + name=playlist_name, + actor=user.actor, + privacy_level=options["privacy_level"], ) return playlist @@ -26,7 +28,9 @@ def get_or_create_playlist(self, playlist_name, user, **options): ) if response.lower() in "yes": playlist = playlist_models.Playlist.objects.create( - name=playlist_name, user=user, privacy_level=options["privacy_level"] + name=playlist_name, + actor=user.actor, + privacy_level=options["privacy_level"], ) return playlist else: diff --git a/api/funkwhale_api/playlists/admin.py b/api/funkwhale_api/playlists/admin.py index 056535270..2cc83d4fe 100644 --- a/api/funkwhale_api/playlists/admin.py +++ b/api/funkwhale_api/playlists/admin.py @@ -5,7 +5,7 @@ from . import models @admin.register(models.Playlist) class PlaylistAdmin(admin.ModelAdmin): - list_display = ["name", "user", "privacy_level", "creation_date"] + list_display = ["name", "actor", "privacy_level", "creation_date"] search_fields = ["name"] list_select_related = True diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py index 5fbddd997..cf31be03f 100644 --- a/api/funkwhale_api/playlists/factories.py +++ b/api/funkwhale_api/playlists/factories.py @@ -1,23 +1,58 @@ import factory +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.music.factories import TrackFactory -from funkwhale_api.users.factories import UserFactory @registry.register class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): name = factory.Faker("name") - user = factory.SubFactory(UserFactory) + actor = factory.SubFactory(ActorFactory) + fid = factory.Faker("federation_url") + uuid = factory.Faker("uuid4") class Meta: model = "playlists.Playlist" + @factory.post_generation + def local(self, create, extracted, **kwargs): + if not extracted and not kwargs: + return + domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[ + 0 + ] + self.fid = f"https://{domain}/federation/music/playlists/{self.uuid}" + self.save(update_fields=["fid"]) + @registry.register class PlaylistTrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): playlist = factory.SubFactory(PlaylistFactory) track = factory.SubFactory(TrackFactory) + fid = factory.Faker("federation_url") + uuid = factory.Faker("uuid4") class Meta: model = "playlists.PlaylistTrack" + + @factory.post_generation + def local(self, create, extracted, **kwargs): + if not extracted and not kwargs: + return + domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[ + 0 + ] + self.fid = f"https://{domain}/federation/music/playlists-tracks/{self.uuid}" + self.save(update_fields=["fid"]) + + +@registry.register +class PlaylistScanFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + playlist = factory.SubFactory(PlaylistFactory) + actor = factory.SubFactory(ActorFactory) + + class Meta: + model = "playlists.PlaylistScan" diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index 22dc2f67d..cccdc9260 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -26,7 +26,7 @@ class PlaylistFilter(filters.FilterSet): queryset=music_models.Artist.objects.all(), distinct=True, ) - scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) + scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True) class Meta: model = models.Playlist @@ -42,5 +42,5 @@ class PlaylistFilter(filters.FilterSet): return queryset.filter(plts_count=0) def filter_q(self, queryset, name, value): - query = utils.get_query(value, ["name", "user__username"]) + query = utils.get_query(value, ["name", "actor__user__username"]) return queryset.filter(query) diff --git a/api/funkwhale_api/playlists/migrations/0005_remove_playlist_user_playlist_actor.py b/api/funkwhale_api/playlists/migrations/0005_remove_playlist_user_playlist_actor.py new file mode 100644 index 000000000..ecf4131e4 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0005_remove_playlist_user_playlist_actor.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.9 on 2024-11-25 12:03 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + +from funkwhale_api.federation import utils +from django.urls import reverse + + +def get_user_actor(apps, schema_editor): + MyModel = apps.get_model("playlists", "Playlist") + for row in MyModel.objects.all(): + actor = row.user.actor + row.actor = actor + row.save(update_fields=["actor"]) + + +def gen_uuid(apps, schema_editor): + MyModel = apps.get_model("playlists", "Playlist") + for row in MyModel.objects.all(): + unique_uuid = uuid.uuid4() + while MyModel.objects.filter(uuid=unique_uuid).exists(): + unique_uuid = uuid.uuid4() + + fid = utils.full_url( + reverse("federation:music:playlist-detail", kwargs={"uuid": unique_uuid}) + ) + row.uuid = unique_uuid + row.fid = fid + row.save(update_fields=["uuid", "fid"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("federation", "0028_auto_20221027_1141"), + ("playlists", "0004_auto_20180320_1713"), + ] + + operations = [ + migrations.AddField( + model_name="playlist", + name="fid", + field=models.URLField(max_length=500 ), + ), + migrations.AddField( + model_name="playlist", + name="url", + field=models.URLField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name="playlist", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="playlist", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, null=False, unique=True), + ), + migrations.AlterField( + model_name="playlist", + name="fid", + field=models.URLField(max_length=500, unique=True, db_index=True, + ),), + migrations.AddField( + model_name="playlist", + name="actor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="playlists", + to="federation.actor", + null=True, + blank=True, + ), + ), + migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="playlist", + name="actor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="track_favorites", + to="federation.actor", + ), + ), + migrations.RemoveField( + model_name="playlist", + name="user", + ), + ] diff --git a/api/funkwhale_api/playlists/migrations/0006_playlisttrack_fid_playlisttrack_url_and_more.py b/api/funkwhale_api/playlists/migrations/0006_playlisttrack_fid_playlisttrack_url_and_more.py new file mode 100644 index 000000000..5a9612893 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0006_playlisttrack_fid_playlisttrack_url_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.9 on 2024-11-28 17:49 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + +from funkwhale_api.federation import utils +from django.urls import reverse + +def gen_uuid(apps, schema_editor): + MyModel = apps.get_model("playlists", "Playlist") + for row in MyModel.objects.all(): + unique_uuid = uuid.uuid4() + while MyModel.objects.filter(uuid=unique_uuid).exists(): + unique_uuid = uuid.uuid4() + + fid = utils.full_url( + reverse("federation:music:playlist-detail", kwargs={"uuid": unique_uuid}) + ) + row.uuid = unique_uuid + row.fid = fid + row.save(update_fields=["uuid", "fid"]) + +class Migration(migrations.Migration): + + dependencies = [ + ("federation", "0028_auto_20221027_1141"), + ("playlists", "0005_remove_playlist_user_playlist_actor"), + ] + + operations = [ + migrations.AddField( + model_name="playlisttrack", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + migrations.AddField( + model_name="playlisttrack", + name="fid", + field=models.URLField(max_length=500 + ), + ), + migrations.AddField( + model_name="playlisttrack", + name="url", + field=models.URLField(blank=True, max_length=500, null=True), + ), + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + migrations.AlterField( + model_name="playlist", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, null=False, unique=True), + ), + migrations.AlterField( + model_name="playlisttrack", + name="fid", + field=models.URLField( + db_index=True, max_length=500, unique=True + ),), + ] diff --git a/api/funkwhale_api/playlists/migrations/0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more.py b/api/funkwhale_api/playlists/migrations/0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more.py new file mode 100644 index 000000000..8676e07b4 --- /dev/null +++ b/api/funkwhale_api/playlists/migrations/0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.9 on 2024-12-03 11:28 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("federation", "0028_auto_20221027_1141"), + ("playlists", "0006_playlisttrack_fid_playlisttrack_url_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="playlist", + name="actor", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="playlists", + to="federation.actor", + ), + ), + migrations.AlterField( + model_name="playlisttrack", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, unique=True), + ), + migrations.CreateModel( + name="PlaylistScan", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("total_files", models.PositiveIntegerField(default=0)), + ("processed_files", models.PositiveIntegerField(default=0)), + ("errored_files", models.PositiveIntegerField(default=0)), + ("status", models.CharField(default="pending", max_length=25)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("modification_date", models.DateTimeField(blank=True, null=True)), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="federation.actor", + ), + ), + ( + "playlist", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scans", + to="playlists.playlist", + ), + ), + ], + ), + ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index ce284e238..9e5812df5 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -1,14 +1,23 @@ +import datetime +import uuid + from django.db import models, transaction from django.db.models import Q from django.db.models.expressions import OuterRef, Subquery +from django.urls import reverse from django.utils import timezone from rest_framework import exceptions -from funkwhale_api.common import fields, preferences +from funkwhale_api.common import fields +from funkwhale_api.common import models as common_models +from funkwhale_api.common import preferences +from funkwhale_api.common import utils as common_utils +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import utils as federation_utils from funkwhale_api.music import models as music_models -class PlaylistQuerySet(models.QuerySet): +class PlaylistQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet): def with_tracks_count(self): return self.annotate(_tracks_count=models.Count("playlist_tracks")) @@ -67,16 +76,18 @@ class PlaylistQuerySet(models.QuerySet): return self.exclude(playlist_tracks__in=plts).distinct() -class Playlist(models.Model): +class Playlist(federation_models.FederationMixin): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) name = models.CharField(max_length=50) - user = models.ForeignKey( - "users.User", related_name="playlists", on_delete=models.CASCADE + actor = models.ForeignKey( + "federation.Actor", related_name="playlists", on_delete=models.CASCADE ) creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField(auto_now=True) privacy_level = fields.get_privacy_field() objects = PlaylistQuerySet.as_manager() + federation_namespace = "playlists" def __str__(self): return self.name @@ -84,6 +95,22 @@ class Playlist(models.Model): def get_absolute_url(self): return f"/library/playlists/{self.pk}" + def get_federation_id(self): + if self.fid: + return self.fid + return federation_utils.full_url( + reverse( + f"federation:music:{self.federation_namespace}-detail", + kwargs={"uuid": self.uuid}, + ) + ) + + def save(self, **kwargs): + if not self.pk and not self.fid: + self.fid = self.get_federation_id() + + return super().save(**kwargs) + @transaction.atomic def insert(self, plt, index=None, allow_duplicates=True): """ @@ -159,9 +186,20 @@ class Playlist(models.Model): self.save(update_fields=["modification_date"]) start = total + plts = [ PlaylistTrack( - creation_date=now, playlist=self, track=track, index=start + i + creation_date=now, + playlist=self, + track=track, + index=start + i, + uuid=(new_uuid := uuid.uuid4()), + fid=federation_utils.full_url( + reverse( + f"federation:music:{self.federation_namespace}-detail", + kwargs={"uuid": new_uuid}, # Use the newly generated UUID + ) + ), ) for i, track in enumerate(tracks) ] @@ -187,8 +225,45 @@ class Playlist(models.Model): } ) + def schedule_scan(self, actor, force=False): + """Update playlist tracks if playlist is a remote one. If it's a local playlist it send an update activity + on the remote server which will trigger a scan""" -class PlaylistTrackQuerySet(models.QuerySet): + latest_scan = ( + self.scans.exclude(status="errored").order_by("-creation_date").first() + ) + delay_between_scans = datetime.timedelta(seconds=3600 * 24) + now = timezone.now() + if ( + not force + and latest_scan + and latest_scan.creation_date + delay_between_scans > now + ): + return + + from . import tasks + + scan = self.scans.create( + total_files=len(self.playlist_tracks.all()), actor=actor + ) + + if self.actor.is_local: + from funkwhale_api.federation import routes + + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Playlist"}}, + context={"playlist": self, "actor": self.actor}, + ) + scan.status = "finished" + return scan + else: + common_utils.on_commit( + tasks.start_playlist_scan.delay, playlist_scan_id=scan.pk + ) + return scan + + +class PlaylistTrackQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet): def for_nested_serialization(self, actor=None): tracks = music_models.Track.objects.with_playable_uploads(actor) tracks = tracks.prefetch_related( @@ -228,7 +303,8 @@ class PlaylistTrackQuerySet(models.QuerySet): return PlaylistTrack.objects.get(pk=plt_id) -class PlaylistTrack(models.Model): +class PlaylistTrack(federation_models.FederationMixin): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) track = models.ForeignKey( "music.Track", related_name="playlist_tracks", on_delete=models.CASCADE ) @@ -239,6 +315,7 @@ class PlaylistTrack(models.Model): creation_date = models.DateTimeField(default=timezone.now) objects = PlaylistTrackQuerySet.as_manager() + federation_namespace = "playlist-tracks" class Meta: ordering = ("-playlist", "index") @@ -251,3 +328,34 @@ class PlaylistTrack(models.Model): if index is not None and update_indexes: playlist.remove(index) return r + + def get_federation_id(self): + if self.fid: + return self.fid + return federation_utils.full_url( + reverse( + f"federation:music:{self.federation_namespace}-detail", + kwargs={"uuid": self.uuid}, + ) + ) + + def save(self, **kwargs): + if not self.pk and not self.fid: + self.fid = self.get_federation_id() + + return super().save(**kwargs) + + +class PlaylistScan(models.Model): + actor = models.ForeignKey( + "federation.Actor", null=True, blank=True, on_delete=models.CASCADE + ) + playlist = models.ForeignKey( + Playlist, related_name="scans", on_delete=models.CASCADE + ) + total_files = models.PositiveIntegerField(default=0) + processed_files = models.PositiveIntegerField(default=0) + errored_files = models.PositiveIntegerField(default=0) + status = models.CharField(default="pending", max_length=25) + creation_date = models.DateTimeField(default=timezone.now) + modification_date = models.DateTimeField(null=True, blank=True) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index edf9aa804..7494c2838 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -5,11 +5,10 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.federation.serializers import APIActorSerializer from funkwhale_api.music import tasks from funkwhale_api.music.models import Album, Artist, Track from funkwhale_api.music.serializers import TrackSerializer -from funkwhale_api.users.serializers import UserBasicSerializer from . import models @@ -33,16 +32,15 @@ class PlaylistSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField(read_only=True) duration = serializers.SerializerMethodField(read_only=True) album_covers = serializers.SerializerMethodField(read_only=True) - user = UserBasicSerializer(read_only=True) is_playable = serializers.SerializerMethodField() - actor = serializers.SerializerMethodField() + actor = APIActorSerializer(read_only=True) class Meta: model = models.Playlist fields = ( "id", "name", - "user", + "actor", "modification_date", "creation_date", "privacy_level", @@ -54,25 +52,12 @@ class PlaylistSerializer(serializers.ModelSerializer): ) read_only_fields = ["id", "modification_date", "creation_date"] - @extend_schema_field(federation_serializers.APIActorSerializer) - def get_actor(self, obj): - actor = obj.user.actor - if actor: - return federation_serializers.APIActorSerializer(actor).data - @extend_schema_field(OpenApiTypes.BOOL) def get_is_playable(self, obj): - try: - return bool(obj.playable_plts) - except AttributeError: - return None + return getattr(obj, "is_playable_by_actor", False) def get_tracks_count(self, obj) -> int: - try: - return obj.tracks_count - except AttributeError: - # no annotation? - return obj.playlist_tracks.count() + return getattr(obj, "tracks_count", obj.playlist_tracks.count()) def get_duration(self, obj) -> int: try: @@ -173,7 +158,7 @@ class XspfSerializer(serializers.Serializer): pl = models.Playlist.objects.create( name=validated_data["title"], privacy_level="private", - user=validated_data["request"].user, + actor=validated_data["request"].user.actor, ) pl.insert_many(validated_data["tracks"]) diff --git a/api/funkwhale_api/playlists/tasks.py b/api/funkwhale_api/playlists/tasks.py new file mode 100644 index 000000000..fd6d51f2d --- /dev/null +++ b/api/funkwhale_api/playlists/tasks.py @@ -0,0 +1,112 @@ +import requests +from django.db.models import F +from django.utils import timezone +from requests.exceptions import RequestException + +from funkwhale_api.common import session +from funkwhale_api.federation import serializers, signing +from funkwhale_api.taskapp import celery + +from . import models + + +def get_playlist_data(playlist_url, actor): + auth = signing.get_auth(actor.private_key, actor.private_key_id) + try: + response = session.get_session().get( + playlist_url, + auth=auth, + headers={"Accept": "application/activity+json"}, + ) + except requests.ConnectionError: + return {"errors": ["This playlist is not reachable"]} + scode = response.status_code + if scode == 401: + return {"errors": ["This playlist requires authentication"]} + elif scode == 403: + return {"errors": ["Permission denied while scanning playlist"]} + elif scode >= 400: + return {"errors": [f"Error {scode} while fetching the playlist"]} + serializer = serializers.PlaylistCollectionSerializer(data=response.json()) + + if not serializer.is_valid(): + return {"errors": ["Invalid ActivityPub response from remote playlist"]} + + return serializer.validated_data + + +def get_playlist_page(playlist, page_url, actor): + auth = signing.get_auth(actor.private_key, actor.private_key_id) + response = session.get_session().get( + page_url, + auth=auth, + headers={"Accept": "application/activity+json"}, + ) + serializer = serializers.CollectionPageSerializer( + data=response.json(), + context={ + "playlist": playlist, + "item_serializer": serializers.PlaylistTrackSerializer, + }, + ) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + +@celery.app.task(name="playlist.start_playlist_scan") +@celery.require_instance( + models.PlaylistScan.objects.select_related().filter(status="pending"), + "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: + playlist_scan.status = "errored" + playlist_scan.save(update_fields=["status", "modification_date"]) + raise + if "errors" in data.keys(): + playlist_scan.status = "errored" + playlist_scan.save(update_fields=["status", "modification_date"]) + raise Exception("Error from remote server : " + str(data)) + playlist_scan.modification_date = timezone.now() + playlist_scan.status = "scanning" + playlist_scan.total_files = data["totalItems"] + + playlist_scan.save(update_fields=["status", "modification_date", "total_files"]) + scan_playlist_page.delay(playlist_scan_id=playlist_scan.pk, page_url=data["first"]) + + +@celery.app.task( + name="playlist.scan_playlist_page", + retry_backoff=60, + max_retries=5, + autoretry_for=[RequestException], +) +@celery.require_instance( + models.PlaylistScan.objects.select_related().filter(status="scanning"), + "playlist_scan", +) +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) + + playlist_scan.processed_files = F("processed_files") + len(tracks) + playlist_scan.modification_date = timezone.now() + update_fields = ["modification_date", "processed_files"] + + next_page = data.get("next") + fetch_next = next_page and next_page != page_url + + if not fetch_next: + update_fields.append("status") + playlist_scan.status = "finished" + playlist_scan.save(update_fields=update_fields) + + if fetch_next: + scan_playlist_page.delay(playlist_scan_id=playlist_scan.pk, page_url=next_page) diff --git a/api/funkwhale_api/playlists/urls.py b/api/funkwhale_api/playlists/urls.py deleted file mode 100644 index f55d35bb3..000000000 --- a/api/funkwhale_api/playlists/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from funkwhale_api.common import routers - -from . import views - -router = routers.OptionalSlashRouter() -router.register(r"playlists", views.PlaylistViewSet, "playlists") - -urlpatterns = router.urls diff --git a/api/funkwhale_api/playlists/urls_v2.py b/api/funkwhale_api/playlists/urls_v2.py deleted file mode 100644 index 36fd2ccea..000000000 --- a/api/funkwhale_api/playlists/urls_v2.py +++ /dev/null @@ -1,9 +0,0 @@ -from funkwhale_api.common import routers - -from . import views - -router = routers.OptionalSlashRouter() - -router.register(r"playlists", views.PlaylistViewSet, "playlists") - -urlpatterns = router.urls diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index ea8a6be29..a5e6f653a 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -3,13 +3,14 @@ import logging from django.db import transaction from django.db.models import Count from drf_spectacular.utils import extend_schema -from rest_framework import exceptions, mixins, viewsets +from rest_framework import exceptions, mixins, status, viewsets from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from funkwhale_api.common import fields, permissions +from funkwhale_api.federation import routes from funkwhale_api.music import models as music_models from funkwhale_api.music import serializers as music_serializers from funkwhale_api.music import utils as music_utils @@ -31,7 +32,7 @@ class PlaylistViewSet( serializer_class = serializers.PlaylistSerializer queryset = ( models.Playlist.objects.all() - .select_related("user__actor__attachment_icon") + .select_related("actor__attachment_icon") .annotate(tracks_count=Count("playlist_tracks", distinct=True)) .with_covers() .with_duration() @@ -43,30 +44,12 @@ class PlaylistViewSet( required_scope = "playlists" anonymous_policy = "setting" owner_checks = ["write"] + owner_field = "actor.user" filterset_class = filters.PlaylistFilter ordering_fields = ("id", "name", "creation_date", "modification_date") parser_classes = [parsers.XspfParser, JSONParser, FormParser, MultiPartParser] renderer_classes = [JSONRenderer, renderers.PlaylistXspfRenderer] - def create(self, request, *args, **kwargs): - content_type = request.headers.get("Content-Type") - if content_type and "application/octet-stream" in content_type: - # We check if tracks are in the db, and exclude the ones we don't find - for track_data in list(request.data.get("tracks", [])): - track_serializer = serializers.XspfTrackSerializer(data=track_data) - if not track_serializer.is_valid(): - request.data["tracks"].remove(track_data) - logger.info( - f"Removing track {track_data} because we didn't find a match in db" - ) - - serializer = serializers.XspfSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - pl = serializer.save(request=request) - return Response(serializers.PlaylistSerializer(pl).data, status=201) - response = super().create(request, *args, **kwargs) - return response - def update(self, request, *args, **kwargs): playlist = self.get_object() content_type = request.headers.get("Content-Type") @@ -87,8 +70,56 @@ class PlaylistViewSet( ) serializer.is_valid(raise_exception=True) pl = serializer.save() + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Playlist"}}, + context={"playlist": pl, "actor": playlist.actor}, + ) return Response(serializers.PlaylistSerializer(pl).data, status=201) - return super().retrieve(request, *args, **kwargs) + + response = super().update(request, *args, **kwargs) + routes.outbox.dispatch( + {"type": "Update", "object": {"type": "Playlist"}}, + context={"playlist": self.get_object(), "actor": playlist.actor}, + ) + return response + + def create(self, request, *args, **kwargs): + content_type = request.headers.get("Content-Type") + if content_type and "application/octet-stream" in content_type: + # We check if tracks are in the db, and exclude the ones we don't find + for track_data in list(request.data.get("tracks", [])): + track_serializer = serializers.XspfTrackSerializer(data=track_data) + if not track_serializer.is_valid(): + request.data["tracks"].remove(track_data) + logger.info( + f"Removing track {track_data} because we didn't find a match in db" + ) + + serializer = serializers.XspfSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + pl = serializer.save(request=request) + return Response(serializers.PlaylistSerializer(pl).data, status=201) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + playlist = self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + routes.outbox.dispatch( + {"type": "Create", "object": {"type": "Playlist"}}, + context={"playlist": playlist, "actor": playlist.actor}, + ) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def destroy(self, request, *args, **kwargs): + playlist = self.get_object() + self.perform_destroy(playlist) + routes.outbox.dispatch( + {"type": "Delete", "object": {"type": "Playlist"}}, + context={"playlist": playlist, "actor": playlist.actor}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema(responses=serializers.PlaylistTrackSerializer(many=True)) @action(methods=["get"], detail=True) @@ -126,6 +157,7 @@ class PlaylistViewSet( ) serializer = serializers.PlaylistTrackSerializer(plts, many=True) data = {"count": len(plts), "results": serializer.data} + playlist.schedule_scan(playlist.actor, force=True) return Response(data, status=201) @extend_schema(operation_id="clear_playlist") @@ -135,16 +167,19 @@ class PlaylistViewSet( playlist = self.get_object() playlist.playlist_tracks.all().delete() playlist.save(update_fields=["modification_date"]) + playlist.schedule_scan(playlist.actor) return Response(status=204) def get_queryset(self): return self.queryset.filter( - fields.privacy_level_query(self.request.user) + fields.privacy_level_query( + self.request.user, "privacy_level", "actor__user" + ) ).with_playable_plts(music_utils.get_actor_from_request(self.request)) def perform_create(self, serializer): return serializer.save( - user=self.request.user, + actor=self.request.user.actor, privacy_level=serializer.validated_data.get( "privacy_level", self.request.user.privacy_level ), @@ -166,7 +201,7 @@ class PlaylistViewSet( except models.PlaylistTrack.DoesNotExist: return Response(status=404) plt.delete(update_indexes=True) - + plt.playlist.schedule_scan(playlist.actor) return Response(status=204) @extend_schema(operation_id="reorder_track_in_playlist") @@ -191,6 +226,7 @@ class PlaylistViewSet( except models.PlaylistTrack.DoesNotExist: return Response(status=404) playlist.insert(plt, to_index) + plt.playlist.schedule_scan(playlist.actor) return Response(status=204) @extend_schema(operation_id="get_playlist_albums") diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 6fa725002..af0899d30 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -254,7 +254,7 @@ def get_playlist_data(playlist): return { "id": playlist.pk, "name": playlist.name, - "owner": playlist.user.username, + "owner": playlist.actor.user.username, "public": "false", "songCount": playlist._tracks_count, "duration": 0, diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index f9a8a35aa..e3f43cc6e 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -100,9 +100,9 @@ def find_object( def get_playlist_qs(request): qs = playlists_models.Playlist.objects.filter( - fields.privacy_level_query(request.user) + fields.privacy_level_query(request.user, "privacy_level", "actor__user") ) - qs = qs.with_tracks_count().exclude(_tracks_count=0).select_related("user") + qs = qs.with_tracks_count().exclude(_tracks_count=0).select_related("actor__user") return qs.order_by("-creation_date") @@ -627,7 +627,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): url_name="update_playlist", url_path="updatePlaylist", ) - @find_object(lambda request: request.user.playlists.all(), field="playlistId") + @find_object(lambda request: request.user.actor.playlists.all(), field="playlistId") def update_playlist(self, request, *args, **kwargs): playlist = kwargs.pop("obj") data = request.GET or request.POST @@ -672,7 +672,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): url_name="delete_playlist", url_path="deletePlaylist", ) - @find_object(lambda request: request.user.playlists.all()) + @find_object(lambda request: request.user.actor.playlists.all()) def delete_playlist(self, request, *args, **kwargs): playlist = kwargs.pop("obj") playlist.delete() @@ -700,7 +700,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): } ) if playListId: - playlist = request.user.playlists.get(pk=playListId) + playlist = request.user.actor.playlists.get(pk=playListId) createPlaylist = False if not name and not playlist: return response.Response( @@ -712,7 +712,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): } ) if createPlaylist: - playlist = request.user.playlists.create(name=name) + playlist = request.user.actor.playlists.create(name=name) ids = [] for i in data.getlist("songId"): try: @@ -731,7 +731,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): pass if sorted_tracks: playlist.insert_many(sorted_tracks) - playlist = request.user.playlists.with_tracks_count().get(pk=playlist.pk) + playlist = request.user.actor.playlists.with_tracks_count().get(pk=playlist.pk) data = {"playlist": serializers.get_playlist_detail_data(playlist)} return response.Response(data) diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py index e2a569839..ee76a9678 100644 --- a/api/tests/common/test_permissions.py +++ b/api/tests/common/test_permissions.py @@ -7,10 +7,12 @@ from funkwhale_api.common import permissions def test_owner_permission_owner_field_ok(nodb_factories, api_request): playlist = nodb_factories["playlists.Playlist"]() + nodb_factories["users.User"](actor=playlist.actor) view = APIView.as_view() permission = permissions.OwnerPermission() request = api_request.get("/") - setattr(request, "user", playlist.user) + setattr(request, "user", playlist.actor.user) + setattr(view, "owner_field", "actor.user") check = permission.has_object_permission(request, view, playlist) assert check is True @@ -24,7 +26,7 @@ def test_owner_permission_owner_field_not_ok( permission = permissions.OwnerPermission() request = api_request.get("/") setattr(request, "user", anonymous_user) - + setattr(view, "owner_field", "actor.user") with pytest.raises(Http404): permission.has_object_permission(request, view, playlist) diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py index fe51edfad..493727c21 100644 --- a/api/tests/federation/test_routes.py +++ b/api/tests/federation/test_routes.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest from funkwhale_api.favorites import models as favorites_models @@ -12,6 +14,7 @@ from funkwhale_api.federation import ( ) from funkwhale_api.history import models as history_models from funkwhale_api.moderation import serializers as moderation_serializers +from funkwhale_api.playlists import models as playlists_models @pytest.mark.parametrize( @@ -21,6 +24,10 @@ from funkwhale_api.moderation import serializers as moderation_serializers ({"type": "Accept"}, routes.inbox_accept), ({"type": "Reject"}, routes.inbox_reject_follow), ({"type": "Create", "object": {"type": "Audio"}}, routes.inbox_create_audio), + ( + {"type": "Create", "object": {"type": "Playlist"}}, + routes.inbox_create_playlist, + ), ( {"type": "Update", "object": {"type": "Library"}}, routes.inbox_update_library, @@ -31,11 +38,19 @@ from funkwhale_api.moderation import serializers as moderation_serializers ), ({"type": "Delete", "object": {"type": "Audio"}}, routes.inbox_delete_audio), ({"type": "Delete", "object": {"type": "Album"}}, routes.inbox_delete_album), + ( + {"type": "Delete", "object": {"type": "Playlist"}}, + routes.inbox_delete_playlist, + ), ({"type": "Undo", "object": {"type": "Follow"}}, routes.inbox_undo_follow), ({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist), ({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album), ({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track), ({"type": "Update", "object": {"type": "Audio"}}, routes.inbox_update_audio), + ( + {"type": "Update", "object": {"type": "Playlist"}}, + routes.inbox_update_playlist, + ), ({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor), ({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete), ({"type": "Flag"}, routes.inbox_flag), @@ -61,6 +76,10 @@ def test_inbox_routes(route, handler): ({"type": "Follow"}, routes.outbox_follow), ({"type": "Reject"}, routes.outbox_reject_follow), ({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio), + ( + {"type": "Create", "object": {"type": "Playlist"}}, + routes.outbox_create_playlist, + ), ( {"type": "Update", "object": {"type": "Library"}}, routes.outbox_update_library, @@ -74,6 +93,10 @@ def test_inbox_routes(route, handler): ({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow), ({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track), ({"type": "Update", "object": {"type": "Audio"}}, routes.outbox_update_audio), + ( + {"type": "Update", "object": {"type": "Playlist"}}, + routes.outbox_update_playlist, + ), ( {"type": "Delete", "object": {"type": "Tombstone"}}, routes.outbox_delete_actor, @@ -93,6 +116,10 @@ def test_inbox_routes(route, handler): {"type": "Like", "object": {"type": "Track"}}, routes.outbox_create_track_favorite, ), + ( + {"type": "Delete", "object": {"type": "Playlist"}}, + routes.outbox_delete_playlist, + ), ], ) def test_outbox_routes(route, handler): @@ -1135,4 +1162,121 @@ def test_inbox_create_listening(factories, mocker): ).exists() -# to do : test dislike +def test_outbox_create_playlist(factories, mocker): + user = factories["users.User"](with_actor=True) + playlist = factories["playlists.Playlist"](actor=user.actor) + + activity = list( + routes.outbox_create_playlist( + {"playlist": playlist, "actor": user.actor, "id": playlist.fid} + ) + )[0] + serializer = serializers.ActivitySerializer( + { + "type": "Create", + "id": playlist.fid, + "actor": playlist.actor, + "object": serializers.PlaylistSerializer(playlist).data, + } + ) + expected = serializer.data + expected["to"] = [{"type": "followers", "target": playlist.actor}] + assert dict(activity["payload"]) == dict(expected) + assert activity["actor"] == playlist.actor + + +def test_inbox_create_playlist(factories, mocker): + actor = factories["federation.Actor"]() + playlist = factories["playlists.Playlist"]( + actor=actor, local=True, privacy_level="everyone" + ) + plt = factories["playlists.PlaylistTrack"](playlist=playlist, index=0, local=True) + + playlist_data = serializers.PlaylistSerializer(playlist).data + init = mocker.spy(serializers.PlaylistSerializer, "__init__") + create = mocker.spy(serializers.PlaylistSerializer, "create") + + mock_session = Mock() + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.json.side_effect = [ + playlist_data, + ] + mock_session.get.return_value = mock_response + mocker.patch( + "funkwhale_api.federation.utils.session.get_session", + return_value=mock_session, + ) + mocker.patch("funkwhale_api.music.tasks.populate_album_cover") + + playlist.delete() + + assert not playlists_models.PlaylistTrack.objects.filter(uuid=plt.uuid).exists() + + result = routes.inbox_create_playlist( + {"object": playlist_data}, + context={ + "actor": playlist.actor, + "raise_exception": True, + }, + ) + + assert init.call_count == 1 + args = init.call_args + args[1]["data"]["updated"] = result["object"].modification_date.isoformat() + assert args[1]["data"] == serializers.PlaylistSerializer(result["object"]).data + assert create.call_count == 1 + + assert playlists_models.Playlist.objects.filter(actor=playlist.actor).exists() + assert playlists_models.Playlist.objects.filter(uuid=playlist.uuid).exists() + # doesn't exist since we use playlist scan to add tracks to the playlist + assert not playlists_models.PlaylistTrack.objects.filter(uuid=plt.uuid).exists() + assert serializers.PlaylistSerializer(result["object"]).data == playlist_data + + +def test_inbox_delete_playlist(factories, mocker): + actor = factories["federation.Actor"]() + playlist = factories["playlists.Playlist"](actor=actor, local=True) + plt = factories["playlists.PlaylistTrack"](playlist=playlist, index=0, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=1, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=2, local=True) + playlist_data = serializers.PlaylistSerializer(playlist).data + + routes.inbox_delete_playlist( + {"object": playlist_data}, + context={ + "actor": plt.playlist.actor, + "raise_exception": True, + }, + ) + assert not playlists_models.Playlist.objects.filter(fid=plt.playlist.fid).exists() + assert not playlists_models.PlaylistTrack.objects.filter(fid=plt.fid).exists() + + +def test_inbox_update_playlist(factories, mocker): + actor = factories["federation.Actor"](local=True) + playlist = factories["playlists.Playlist"]( + actor=actor, local=True, privacy_level="everyone" + ) + playlist_updated = factories["playlists.Playlist"]( + actor=actor, local=True, privacy_level="everyone" + ) + + factories["playlists.PlaylistTrack"](playlist=playlist, index=0, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=1, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=2, local=True) + + playlist_data = serializers.PlaylistSerializer(playlist_updated).data + playlist_data["id"] = str(playlist.fid) + + routes.inbox_update_playlist( + {"object": playlist_data}, + context={ + "actor": playlist.actor, + "raise_exception": True, + }, + ) + should_be_updated = playlists_models.Playlist.objects.get(fid=playlist.fid) + expected = serializers.PlaylistSerializer(should_be_updated).data + playlist_data["updated"] = expected["updated"] + assert serializers.PlaylistSerializer(should_be_updated).data == playlist_data diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 4a6c2c653..eafd149d0 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -5,7 +5,7 @@ from django.urls import reverse from funkwhale_api.common import utils from funkwhale_api.federation import actors, serializers from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.federation import webfinger +from funkwhale_api.federation import views, webfinger def test_authenticate_allows_anonymous_actor_fetch_when_allow_list_enabled( @@ -765,3 +765,40 @@ def test_get_listening(factories, logged_in_api_client, privacy_level, expected) ) response = logged_in_api_client.get(url) assert response.status_code == expected + + +def test_playlist_retrieve(factories, api_client): + playlist = factories["playlists.Playlist"](local=True) + url = reverse("federation:music:playlists-detail", kwargs={"uuid": playlist.uuid}) + response = api_client.get(url) + expected = serializers.PlaylistCollectionSerializer(playlist).data + + assert response.status_code == 200 + assert response.data == expected + + +def test_playlist_get_collection_response(factories, mocker): + actor = factories["federation.Actor"]() + playlist = factories["playlists.Playlist"](actor=actor, local=True) + plt = factories["playlists.PlaylistTrack"](playlist=playlist, index=4, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=0, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=1, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=2, local=True) + factories["playlists.PlaylistTrack"](playlist=playlist, index=3, local=True) + + conf = { + "id": playlist.fid, + "actor": playlist.actor, + "items": playlist.playlist_tracks.order_by("index").prefetch_related( + "track", + ), + "item_serializer": serializers.PlaylistTrackSerializer, + } + playlist_data = views.get_collection_response( + conf=conf, + querystring={"uuid": playlist.uuid, "page": 1}, + collection_serializer=serializers.PlaylistCollectionSerializer(playlist), + ) + + assert playlist_data.data["totalItems"] == 5 + assert playlist_data.data["items"][4]["track"] == plt.track.fid diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py index 06a14781e..dffe79d2e 100644 --- a/api/tests/moderation/test_serializers.py +++ b/api/tests/moderation/test_serializers.py @@ -159,7 +159,7 @@ def test_report_serializer_save_anonymous(factories, mocker): ("music.Album", {"attributed": True}, "attributed_to"), ("music.Track", {"attributed": True}, "attributed_to"), ("music.Library", {}, "actor"), - ("playlists.Playlist", {"user__with_actor": True}, "user.actor"), + ("playlists.Playlist", {}, "actor"), ("federation.Actor", {}, "self"), ("audio.Channel", {}, "attributed_to"), ], diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py index f98f4689b..be74de5a6 100644 --- a/api/tests/music/test_commands.py +++ b/api/tests/music/test_commands.py @@ -252,7 +252,7 @@ def test_prune_non_mbid_content(factories): def test_create_playlist_from_folder_structure(factories, tmp_path): - user = factories["users.User"]() + user = factories["users.User"](with_actor=True) c = create_playlist_from_folder_structure.Command() options = { "dir_name": DATA_DIR, diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py index 92ffbf4ed..8cbd9a02b 100644 --- a/api/tests/playlists/test_models.py +++ b/api/tests/playlists/test_models.py @@ -3,7 +3,7 @@ from rest_framework import exceptions def test_can_insert_plt(factories): - plt = factories["playlists.PlaylistTrack"]() + plt = factories["playlists.PlaylistTrack"](index=None) modification_date = plt.playlist.modification_date assert plt.index is None diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 15624b9f2..631a5ac49 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -1,6 +1,5 @@ from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.playlists import serializers -from funkwhale_api.users import serializers as users_serializers def test_playlist_serializer_include_covers(factories, api_request): @@ -73,17 +72,16 @@ def test_playlist_serializer_include_duration(tmpfile, factories): def test_playlist_serializer(factories, to_api_date): playlist = factories["playlists.Playlist"]() - actor = playlist.user.create_actor() + actor = playlist.actor expected = { "id": playlist.pk, "name": playlist.name, "privacy_level": playlist.privacy_level, - "is_playable": None, + "is_playable": False, "creation_date": to_api_date(playlist.creation_date), "modification_date": to_api_date(playlist.modification_date), "actor": federation_serializers.APIActorSerializer(actor).data, - "user": users_serializers.UserBasicSerializer(playlist.user).data, "duration": 0, "tracks_count": 0, "album_covers": [], diff --git a/api/tests/playlists/test_tasks.py b/api/tests/playlists/test_tasks.py new file mode 100644 index 000000000..4af75c766 --- /dev/null +++ b/api/tests/playlists/test_tasks.py @@ -0,0 +1,138 @@ +from django.core.paginator import Paginator + +from funkwhale_api.federation import serializers as federation_serializers +from funkwhale_api.playlists import tasks + + +def test_scan_playlist_page_fetches_page_and_creates_tracks( + now, mocker, factories, r_mock +): + scan_page = mocker.patch("funkwhale_api.playlists.tasks.scan_playlist_page.delay") + scan = factories["playlists.PlaylistScan"](status="scanning", total_files=5) + tracks = [ + factories["playlists.PlaylistTrack"]( + playlist=scan.playlist, + index=i, + ) + for i in range(5) + ] + + page_conf = { + "actor": scan.playlist.actor, + "id": scan.playlist.fid, + "page": Paginator(tracks, 3).page(1), + "item_serializer": federation_serializers.PlaylistTrackSerializer, + } + tracks[0].__class__.objects.filter(pk__in=[u.pk for u in tracks]).delete() + page = federation_serializers.CollectionPageSerializer(page_conf) + + r_mock.get(page.data["id"], json=page.data) + + tasks.scan_playlist_page(playlist_scan_id=scan.pk, page_url=page.data["id"]) + + scan.refresh_from_db() + plts = list(scan.playlist.playlist_tracks.all().order_by("-creation_date")) + + assert len(plts) == 3 + for track in tracks[:3]: + scan.playlist.playlist_tracks.get(fid=track.fid) + + assert scan.status == "scanning" + assert scan.processed_files == 3 + assert scan.modification_date == now + + scan_page.assert_called_once_with( + playlist_scan_id=scan.pk, page_url=page.data["next"] + ) + + +def test_scan_playlist_fetches_page_and_calls_scan_page(now, mocker, factories, r_mock): + scan = factories["playlists.PlaylistScan"]() + factories["playlists.PlaylistTrack"].create_batch(size=10, playlist=scan.playlist) + collection_conf = { + "actor": scan.playlist.actor, + "id": scan.playlist.fid, + "page_size": 10, + "items": range(10), + "type": "Playlist", + "name": "hello", + } + collection = federation_serializers.PlaylistCollectionSerializer(scan.playlist) + data = collection.data + data["followers"] = "https://followers.domain" + scan_page = mocker.patch("funkwhale_api.playlists.tasks.scan_playlist_page.delay") + r_mock.get(collection_conf["id"], json=data) + tasks.start_playlist_scan(playlist_scan_id=scan.pk) + + scan_page.assert_called_once_with(playlist_scan_id=scan.pk, page_url=data["first"]) + scan.refresh_from_db() + + assert scan.status == "scanning" + assert scan.total_files == len(collection_conf["items"]) + assert scan.modification_date == now + + +def test_scan_page_fetches_page_and_creates_tracks(now, mocker, factories, r_mock): + scan_page = mocker.patch("funkwhale_api.playlists.tasks.scan_playlist_page.delay") + scan = factories["playlists.PlaylistScan"](status="scanning", total_files=5) + tracks = [ + factories["playlists.PlaylistTrack"]( + playlist=scan.playlist, + index=i, + ) + for i in range(5) + ] + + page_conf = { + "actor": scan.playlist.actor, + "id": scan.playlist.fid, + "page": Paginator(tracks, 3).page(1), + "item_serializer": federation_serializers.PlaylistTrackSerializer, + } + tracks[0].__class__.objects.filter(pk__in=[u.pk for u in tracks]).delete() + page = federation_serializers.CollectionPageSerializer(page_conf) + + r_mock.get(page.data["id"], json=page.data) + + tasks.scan_playlist_page(playlist_scan_id=scan.pk, page_url=page.data["id"]) + + scan.refresh_from_db() + lts = list(scan.playlist.playlist_tracks.all().order_by("-creation_date")) + + assert len(lts) == 3 + for track in tracks[:3]: + scan.playlist.playlist_tracks.get(fid=track.fid) + + assert scan.status == "scanning" + assert scan.processed_files == 3 + assert scan.modification_date == now + + scan_page.assert_called_once_with( + playlist_scan_id=scan.pk, page_url=page.data["next"] + ) + + +def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock): + patched_scan = mocker.patch( + "funkwhale_api.playlists.tasks.scan_playlist_page.delay" + ) + scan = factories["playlists.PlaylistScan"](status="scanning", total_files=5) + uploads = factories["playlists.PlaylistTrack"].build_batch( + size=5, playlist=scan.playlist + ) + page_conf = { + "actor": scan.playlist.actor, + "id": scan.playlist.fid, + "page": Paginator(uploads, 3).page(1), + "item_serializer": federation_serializers.PlaylistTrackSerializer, + } + page = federation_serializers.CollectionPageSerializer(page_conf) + data = page.data + data["next"] = data["id"] + r_mock.get(page.data["id"], json=data) + + tasks.scan_playlist_page(playlist_scan_id=scan.pk, page_url=data["id"]) + patched_scan.assert_not_called() + scan.refresh_from_db() + + assert scan.status == "finished" diff --git a/api/tests/playlists/test_urls_v2.py b/api/tests/playlists/test_urls_v2.py index bdc327c91..53355df0b 100644 --- a/api/tests/playlists/test_urls_v2.py +++ b/api/tests/playlists/test_urls_v2.py @@ -8,7 +8,7 @@ from django.urls import reverse def test_can_get_playlist_list(factories, logged_in_api_client): logged_in_api_client.user.create_actor() factories["playlists.Playlist"].create_batch(5) - url = reverse("api:v2:playlists:playlists-list") + url = reverse("api:v2:playlists-list") headers = {"Content-Type": "application/json"} response = logged_in_api_client.get(url, headers=headers) data = json.loads(response.content) @@ -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:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) 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:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) response = logged_in_api_client.get(url, format="json") assert response.status_code == 200 assert response.data["name"] == pl.name @@ -44,10 +44,10 @@ def test_can_get_playlists_json(factories, logged_in_api_client): def test_can_get_user_playlists_list(factories, logged_in_api_client): logged_in_api_client.user.create_actor() - user = factories["users.User"]() - factories["playlists.Playlist"](user=user) + user = factories["users.User"](with_actor=True) + factories["playlists.Playlist"](actor=user.actor) - url = reverse("api:v2:playlists:playlists-list") + url = reverse("api:v2:playlists-list") url = resolve_url(url) + "?user=me" response = logged_in_api_client.get(url) data = json.loads(response.content.decode("utf-8")) @@ -59,7 +59,7 @@ def test_can_get_user_playlists_list(factories, logged_in_api_client): def test_can_post_user_playlists(logged_in_api_client): logged_in_api_client.user.create_actor() playlist = {"name": "Les chiennes de l'hexagone", "privacy_level": "me"} - url = reverse("api:v2:playlists:playlists-list") + url = reverse("api:v2:playlists-list") response = logged_in_api_client.post(url, playlist, format="json") data = json.loads(response.content.decode("utf-8")) @@ -77,7 +77,7 @@ def test_can_post_playlists_octet_stream(factories, logged_in_api_client): factories["music.Track"]( title="Opinel 12", artist_credit__artist=artist, album=album ) - url = reverse("api:v2:playlists:playlists-list") + url = reverse("api:v2:playlists-list") data = open("./tests/playlists/test.xspf", "rb").read() response = logged_in_api_client.post(url, data=data, format="xspf") data = json.loads(response.content) @@ -87,7 +87,7 @@ def test_can_post_playlists_octet_stream(factories, logged_in_api_client): def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_client): logged_in_api_client.user.create_actor() - url = reverse("api:v2:playlists:playlists-list") + url = reverse("api:v2:playlists-list") data = open("./tests/playlists/test.xspf", "rb").read() response = logged_in_api_client.post(url, data=data, format="xspf") data = json.loads(response.content) @@ -97,7 +97,7 @@ def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_ def test_can_patch_playlists_octet_stream(factories, logged_in_api_client): logged_in_api_client.user.create_actor() - pl = factories["playlists.Playlist"](user=logged_in_api_client.user) + pl = factories["playlists.Playlist"](actor=logged_in_api_client.user.actor) artist = factories["music.Artist"](name="Davinhor") album = factories["music.Album"]( title="Racisme en pls", artist_credit__artist=artist @@ -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:playlists-detail", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk}) 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:playlists-tracks", kwargs={"pk": pl.pk}) + url = reverse("api:v2:playlists-tracks", kwargs={"pk": pl.pk}) 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:playlists-albums", kwargs={"pk": playlist.pk}) + url = reverse("api:v2:playlists-albums", kwargs={"pk": playlist.pk}) 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:playlists-artists", kwargs={"pk": playlist.pk}) + url = reverse("api:v2:playlists-artists", kwargs={"pk": playlist.pk}) 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 f1a61f835..30bfd27d8 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -5,22 +5,22 @@ from funkwhale_api.playlists import models def test_can_create_playlist_via_api(logged_in_api_client): - logged_in_api_client.user.create_actor() + actor = logged_in_api_client.user.create_actor() url = reverse("api:v1:playlists-list") data = {"name": "test", "privacy_level": "everyone"} logged_in_api_client.post(url, data) - playlist = logged_in_api_client.user.playlists.latest("id") + playlist = models.Playlist.objects.latest("id") assert playlist.name == "test" + assert playlist.actor == actor assert playlist.privacy_level == "everyone" def test_serializer_includes_tracks_count(factories, logged_in_api_client): - logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"]() + 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}) response = logged_in_api_client.get(url, content_type="application/json") @@ -29,7 +29,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client): def test_serializer_includes_tracks_count_986(factories, logged_in_api_client): logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"]() + playlist = factories["playlists.Playlist"](privacy_level="everyone") plt = factories["playlists.PlaylistTrack"](playlist=playlist) factories["music.Upload"].create_batch( 3, track=plt.track, library__privacy_level="everyone", import_status="finished" @@ -52,16 +52,16 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client): def test_playlist_inherits_user_privacy(logged_in_api_client): - logged_in_api_client.user.create_actor() url = reverse("api:v1:playlists-list") user = logged_in_api_client.user + user.create_actor() user.privacy_level = "me" user.save() data = {"name": "test"} logged_in_api_client.post(url, data) - playlist = user.playlists.latest("id") + playlist = models.Playlist.objects.filter(actor=user.actor).latest("id") assert playlist.privacy_level == user.privacy_level @@ -72,7 +72,7 @@ def test_playlist_inherits_user_privacy(logged_in_api_client): def test_url_requires_login(name, method, factories, api_client): url = reverse(name) - response = getattr(api_client, method)(url, {}) + response = getattr(api_client, method.lower())(url, {}) assert response.status_code == 401 @@ -90,10 +90,10 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client): - logged_in_api_client.user.create_actor() + actor = logged_in_api_client.user.create_actor() remove = mocker.spy(models.Playlist, "remove") factories["music.Track"]() - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + 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}) @@ -133,8 +133,8 @@ def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client): def test_can_add_multiple_tracks_at_once_via_api( factories, mocker, logged_in_api_client ): - logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) tracks = factories["music.Track"].create_batch(size=5) track_ids = [t.id for t in tracks] mocker.spy(playlist, "insert_many") @@ -150,9 +150,9 @@ def test_can_add_multiple_tracks_at_once_via_api( def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, preferences): - logged_in_api_client.user.create_actor() + actor = logged_in_api_client.user.create_actor() preferences["playlists__max_tracks"] = 3 - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + playlist = factories["playlists.Playlist"](actor=actor) tracks = factories["music.Track"].create_batch( size=preferences["playlists__max_tracks"] + 1 ) @@ -165,8 +165,8 @@ def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, prefer def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client): - logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + 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}) response = logged_in_api_client.delete(url) @@ -176,20 +176,20 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client): def test_update_playlist_from_api(factories, mocker, logged_in_api_client): - logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + 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}) response = logged_in_api_client.patch(url, {"name": "test"}) playlist.refresh_from_db() assert response.status_code == 200 - assert response.data["user"]["username"] == playlist.user.username + assert response.data["actor"]["full_username"] == playlist.actor.full_username def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client): - logged_in_api_client.user.create_actor() - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + actor = logged_in_api_client.user.create_actor() + 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}) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 2b9df3a36..295345736 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -270,11 +270,12 @@ def test_get_album_list2_serializer(factories): def test_playlist_serializer(factories): plt = factories["playlists.PlaylistTrack"]() playlist = plt.playlist + factories["users.User"](actor=playlist.actor) qs = music_models.Album.objects.with_tracks_count().order_by("pk") expected = { "id": playlist.pk, "name": playlist.name, - "owner": playlist.user.username, + "owner": playlist.actor.user.username, "public": "false", "songCount": 1, "duration": 0, @@ -289,11 +290,12 @@ def test_playlist_detail_serializer(factories): plt = factories["playlists.PlaylistTrack"]() upload = factories["music.Upload"](track=plt.track) playlist = plt.playlist + factories["users.User"](actor=playlist.actor) qs = music_models.Album.objects.with_tracks_count().order_by("pk") expected = { "id": playlist.pk, "name": playlist.name, - "owner": playlist.user.username, + "owner": playlist.actor.user.username, "public": "false", "songCount": 1, "duration": 0, diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 40978b283..c2878a673 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -10,6 +10,7 @@ import funkwhale_api from funkwhale_api.moderation import filters as moderation_filters from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views +from funkwhale_api.playlists import models from funkwhale_api.subsonic import renderers, serializers @@ -648,8 +649,9 @@ def test_get_playlists(f, db, logged_in_api_client, factories): logged_in_api_client.user.create_actor() url = reverse("api:subsonic:subsonic-get_playlists") assert url.endswith("getPlaylists") is True + playlist1 = factories["playlists.PlaylistTrack"]( - playlist__user=logged_in_api_client.user + playlist__actor__user=logged_in_api_client.user ).playlist playlist2 = factories["playlists.PlaylistTrack"]( playlist__privacy_level="everyone" @@ -658,9 +660,16 @@ def test_get_playlists(f, db, logged_in_api_client, factories): playlist__privacy_level="instance" ).playlist # private - factories["playlists.PlaylistTrack"](playlist__privacy_level="me") + plt = factories["playlists.PlaylistTrack"](playlist__privacy_level="me") # no track - factories["playlists.Playlist"](privacy_level="everyone") + playlist4 = factories["playlists.Playlist"](privacy_level="everyone") + + factories["users.User"](actor=playlist1.actor) + factories["users.User"](actor=playlist2.actor) + factories["users.User"](actor=playlist3.actor) + factories["users.User"](actor=playlist4.actor) + factories["users.User"](actor=plt.playlist.actor) + response = logged_in_api_client.get(url, {"f": f}) qs = ( @@ -681,8 +690,10 @@ def test_get_playlist(f, db, logged_in_api_client, factories): url = reverse("api:subsonic:subsonic-get_playlist") assert url.endswith("getPlaylist") is True playlist = factories["playlists.PlaylistTrack"]( - playlist__user=logged_in_api_client.user + playlist__actor__user=logged_in_api_client.user ).playlist + factories["users.User"](actor=playlist.actor) + response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk}) qs = playlist.__class__.objects.with_tracks_count() @@ -696,7 +707,8 @@ def test_get_playlist(f, db, logged_in_api_client, factories): def test_update_playlist(f, db, logged_in_api_client, factories): url = reverse("api:subsonic:subsonic-update_playlist") assert url.endswith("updatePlaylist") is True - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) factories["playlists.PlaylistTrack"](index=0, playlist=playlist) new_track = factories["music.Track"]() response = logged_in_api_client.get( @@ -720,7 +732,8 @@ def test_update_playlist(f, db, logged_in_api_client, factories): def test_delete_playlist(f, db, logged_in_api_client, factories): url = reverse("api:subsonic:subsonic-delete_playlist") assert url.endswith("deletePlaylist") is True - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk}) assert response.status_code == 200 with pytest.raises(playlist.__class__.DoesNotExist): @@ -733,11 +746,12 @@ def test_create_playlist(f, db, logged_in_api_client, factories): assert url.endswith("createPlaylist") is True track1 = factories["music.Track"]() track2 = factories["music.Track"]() + actor = logged_in_api_client.user.create_actor() response = logged_in_api_client.get( url, {"f": f, "name": "hello", "songId": [track1.pk, track2.pk]} ) assert response.status_code == 200 - playlist = logged_in_api_client.user.playlists.latest("id") + playlist = models.Playlist.objects.filter(actor=actor).latest("id") assert playlist.playlist_tracks.count() == 2 for i, t in enumerate([track1, track2]): plt = playlist.playlist_tracks.get(track=t) @@ -753,7 +767,8 @@ def test_create_playlist(f, db, logged_in_api_client, factories): def test_create_playlist_with_update(f, db, logged_in_api_client, factories): url = reverse("api:subsonic:subsonic-create_playlist") assert url.endswith("createPlaylist") is True - playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + actor = logged_in_api_client.user.create_actor() + playlist = factories["playlists.Playlist"](actor=actor) factories["playlists.PlaylistTrack"](index=0, playlist=playlist) track1 = factories["music.Track"]() track2 = factories["music.Track"]() diff --git a/changes/changelog.d/1458.feature b/changes/changelog.d/1458.feature new file mode 100644 index 000000000..846952c0d --- /dev/null +++ b/changes/changelog.d/1458.feature @@ -0,0 +1 @@ +Playlist federation (#1458) diff --git a/compose/app.django.yml b/compose/app.django.yml index bafbc9bd1..d908b0731 100644 --- a/compose/app.django.yml +++ b/compose/app.django.yml @@ -30,4 +30,8 @@ services: celeryworker: <<: *django - command: celery -A funkwhale_api.taskapp worker -l debug -B --concurrency=${CELERYD_CONCURRENCY} + command: > + sh -c ' + pip install watchdog[watchmedo] && + watchmedo auto-restart --patterns="*.py" --recursive -- celery -A funkwhale_api.taskapp worker -l debug -B --concurrency=${CELERYD_CONCURRENCY} + ' diff --git a/docs/developer/federation/index.md b/docs/developer/federation/index.md index 7568ddc65..38619c31f 100644 --- a/docs/developer/federation/index.md +++ b/docs/developer/federation/index.md @@ -846,3 +846,77 @@ An `Audio` object is a custom object used to store upload information. It extend Funkwhale uses the `attributedTo` property to denote the actor responsible for an object. If an object has an `attributedTo` attributed, the associated actor can perform activities to it, including [`Update`](#update) and [`Delete`](#delete). Funkwhale also attributes all objects on a domain with the domain's [Service actor](#service-actor) + +## Scapping Collections + +Playlists objects are a custom ordered collection[Ordered Collection](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) containing `PlaylistTracks` objects. +The `id` of the playlist is the endpoint where playlist information can be gathered. If no page is specified it will only give the playlist metadata : + +```{code-block} json +{ + "id": "https://node1.funkwhale.test/federation/music/playlists/c1c36f15-f49e-4da6-abd4-17b9b438c348", + "attributedTo": "https://node1.funkwhale.test/federation/actors/node1", + "totalItems": 0, + "type": "Playlist", + "current": "https://node1.funkwhale.test/federation/music/playlists/c1c36f15-f49e-4da6-abd4-17b9b438c348?page=1", + "first": "https://node1.funkwhale.test/federation/music/playlists/c1c36f15-f49e-4da6-abd4-17b9b438c348?page=1", + "last": "https://node1.funkwhale.test/federation/music/playlists/c1c36f15-f49e-4da6-abd4-17b9b438c348?page=1", + "name": "zef", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://funkwhale.audio/ns", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag", + }, + ], +} + +``` + +Note that a limited amount of information is send. Full [Playlist](###Playlist) objects are sent through Activities. + +The [PlaylisTracks](###PlaylistTrack) will be sent in a [CollectionPage](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage) if a page is specified in the playlist url : + +```{code-block} json +{ + "id": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c?page=1", + "partOf": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c", + "totalItems": 5, + "type": "CollectionPage", + "first": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c?page=1", + "last": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c?page=1", + "items": [ + { + "type": "PlaylistTrack", + "id": "https://test.federation/federation/music/playlists/2861fc4a-f3b6-4740-8586-c4573140b994", + "track": "https://simon.biz//34d56bbd-5096-4ac7-ada9-2d11ea731317", + "index": 0, + "attributedTo": "https://wallace-salazar.com/users/ryanrachel953", + "published": "2024-12-04T11:50:16.625013+00:00", + "playlist": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c", + }, + { + "type": "PlaylistTrack", + "id": "https://test.federation/federation/music/playlists/96a46881-9544-438a-9e34-b7a1b5ecbc7a", + "track": "https://fuller.info//a8977c57-5704-469a-a2ae-fa7b213bb370", + "index": 1, + "attributedTo": "https://wallace-salazar.com/users/ryanrachel953", + "published": "2024-12-04T11:50:16.631200+00:00", + "playlist": "https://test.federation/federation/music/playlists/1efba9b2-8218-4ac2-bdce-f9dd8bbd510c", + }, + ], + "attributedTo": "https://wallace-salazar.com/users/ryanrachel953", + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://funkwhale.audio/ns", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag", + }, + ], +} + +``` diff --git a/docs/developer/federation/privacy.md b/docs/developer/federation/privacy.md new file mode 100644 index 000000000..5e38c4224 --- /dev/null +++ b/docs/developer/federation/privacy.md @@ -0,0 +1,22 @@ +# Privacy features for federation + +## General logic + +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` + +### 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. + +## Followers privacy_level + +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-federation/index.md b/docs/specs/playlist-federation/index.md index 9361fe5a8..4c78e9079 100644 --- a/docs/specs/playlist-federation/index.md +++ b/docs/specs/playlist-federation/index.md @@ -14,14 +14,20 @@ Users will be able to click on a "Follow playlist" button. The playlist content #### Backend -Adding a playlist to a library is an ActivityPub `Follow`. The follow request is made to an actor specially created for the playlist. -Endpoints and logic should follow the actual ActivityPub implementation : +In the context of an user A following user B owner of Playlist B. The User A will receive an `Create` activity when User B create a playlist. `Update` activities with `Playlist` objects will be send to the Instance A service actor. They **don't** contain PlalistTracks, only the playlist metatadat is added to database. Playlist tracks are imported thanks to the playlist scan. Or in some case through playlist track create activity. -- The follow request is accepted automatically if the playlist is public -- When accepted, the playlist is added to the local pod, the playlist actor is created has followed by the local actor +Since `PlaylistTrack` object can be updated a lot, instead of sending a bunch of `PlaylistTrack` updates we only send one `Playlist` update (default is on per day, defined in `schedule_scan` function). We use a celery task, it will send an playlist `Update` activity to remote servers if playlist is a local one and will trigger a playlist scan if playlist is a remote one. -For better understandability, the playlist actor should be named after the playlist name and the user actor owning the playlist. For example, if John has a "Rock" playlist, the actor should be called: john_rock_playlist. -Add playlist update activities to notifications. +To follow activitypub standard and since playlist metadata update shouldn't happen to much we will trigger a playlist scan each time we receive a playlist update activiy. + +The scan will get the playlist track by querying the playlist federation endpoint. It return a ordered Collection. Each element of the collection is added to the local database. +When the scan start we delete all `PlaylistTracks` from the playlist. I could be more optimized to send `Delete activities` on `PlaylistTrack` objects. But since were are not sure and since and way more easy to delete the tracks we do it this way for now. + +The `PlaylistTrack` object will only support `Create` activities, since update or delete would trigger a lot of them and they are not interesting (we use playlist scan instead). +`Create` activities will be send to User A followers. +If a `PlaylistTrack` `Create` is sent and the index is not the good one it eans the receiving instance isn't up to date -> we trigger a full playlistscan + +This will allow to receive notification when a user Add a track to a playlist. Other playlist actions will be silent but the playlist will be kept updated. #### Frontend @@ -42,3 +48,9 @@ Add playlist update activities to notifications. ### Minimum Viable Product ### Next Steps + +- [ ] Add playlist update activities to notifications. +- [ ] Create a frontend thread with Update Playlist activities +- [ ] Update the federation search to include Playlist objects +- [ ] Adding a playlist to a user library as an ActivityPub `Like` +- [ ] Check if sending whole big playlists become a problem. diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index 89c6505a6..a9483a462 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -53,8 +53,8 @@ const images = computed(() => {