Playlist federation (#1458)
This commit is contained in:
parent
4bfa1feacf
commit
fedd340ed5
|
@ -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 = {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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}],
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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
|
||||
),),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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"])
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": [],
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"]()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Playlist federation (#1458)
|
|
@ -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}
|
||||
'
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
```
|
||||
|
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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')"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
Loading…
Reference in New Issue