Playlist federation (#1458)

This commit is contained in:
petitminion 2025-01-03 18:17:25 +00:00
parent 4bfa1feacf
commit fedd340ed5
48 changed files with 1478 additions and 180 deletions

View File

@ -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 = {

View File

@ -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")

View File

@ -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

View File

@ -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"},
}
},
},

View File

@ -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}],
),
}

View File

@ -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

View File

@ -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)
},
)

View File

@ -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")

View File

@ -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),
)

View File

@ -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,
}

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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",
),
]

View File

@ -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
),),
]

View File

@ -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",
),
),
],
),
]

View File

@ -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)

View File

@ -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"])

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"),
],

View File

@ -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,

View File

@ -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

View File

@ -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": [],

View File

@ -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"

View File

@ -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

View File

@ -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})

View File

@ -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,

View File

@ -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"]()

View File

@ -0,0 +1 @@
Playlist federation (#1458)

View File

@ -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}
'

View File

@ -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",
},
],
}
```

View File

@ -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.

View File

@ -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.

View File

@ -53,8 +53,8 @@ const images = computed(() => {
</router-link>
</strong>
<div class="description">
<user-link
:user="playlist.user"
<actor-link
:actor="playlist.actor"
:avatar="false"
class="left floated"
/>

View File

@ -95,7 +95,7 @@ const triggerFileInput = () => {
{{ labels.export }}
</div>
<div
v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id"
v-if="$store.state.auth.authenticated && playlist.actor.full_username !== $store.state.auth.fullUsername"
role="button"
class="basic item"
:title="t('components.playlists.PlaylistDropdown.button.import.description')"

View File

@ -3731,7 +3731,7 @@
"header": {
"accountData": "Account data",
"activePolicy": "This domain is subject to specific moderation rules",
"activity": "Activty",
"activity": "Activity",
"audioContent": "Audio content",
"localAccount": "Local account",
"noPolicy": "You don't have any rule in place for this account."
@ -3818,7 +3818,7 @@
},
"header": {
"activePolicy": "This domain is subject to specific moderation rules",
"activity": "Activty",
"activity": "Activity",
"audioContent": "Audio content",
"instanceData": "Instance data",
"noPolicy": "You don't have any rule in place for this domain."

View File

@ -198,7 +198,7 @@ export interface Playlist {
id: number
name: string
modification_date: string
user: User
actor: Actor
privacy_level: PrivacyLevel
tracks_count: number
duration: number

View File

@ -95,7 +95,7 @@ const deletePlaylist = async () => {
<div class="content">
{{ playlist.name }}
<div class="sub header">
{{ $t('views.playlists.Detail.meta.tracks', { username: playlist.user.username }, playlist.tracks_count) }}
{{ $t('views.playlists.Detail.meta.tracks', { username: playlist.actor.name }, playlist.tracks_count) }}
<br>
<duration :seconds="playlist.duration" />
</div>
@ -114,7 +114,7 @@ const deletePlaylist = async () => {
</div>
<div class="ui buttons">
<button
v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile?.id"
v-if="$store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
class="ui icon labeled button"
@click="edit = !edit"
>
@ -137,7 +137,7 @@ const deletePlaylist = async () => {
{{ $t('views.playlists.Detail.button.embed') }}
</button>
<dangerous-button
v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id"
v-if="$store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
class="ui labeled danger icon button"
:action="deletePlaylist"
>