Playlist federation (#1458)
This commit is contained in:
parent
4bfa1feacf
commit
fedd340ed5
|
@ -17,12 +17,6 @@ v2_patterns += [
|
||||||
r"^radios/",
|
r"^radios/",
|
||||||
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="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 = {
|
v2_paths = {
|
||||||
|
|
|
@ -7,7 +7,6 @@ from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerial
|
||||||
from . import models
|
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):
|
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
object = TrackActivitySerializer(source="track")
|
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)
|
tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay)
|
||||||
|
|
||||||
|
|
||||||
def activity_pass_privacy_level(context, routing):
|
def activity_pass_user_privacy_level(context, routing):
|
||||||
TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like"]
|
TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like", "Create"]
|
||||||
TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"]
|
TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"]
|
||||||
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
|
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
|
||||||
|
|
||||||
|
@ -308,6 +308,16 @@ def activity_pass_privacy_level(context, routing):
|
||||||
type = routing.get("type", False)
|
type = routing.get("type", False)
|
||||||
object_type = routing.get("object", {}).get("type", None)
|
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:
|
||||||
if type in TYPE_IGNORE_USER_PRIVACY_LEVEL:
|
if type in TYPE_IGNORE_USER_PRIVACY_LEVEL:
|
||||||
return True
|
return True
|
||||||
|
@ -317,12 +327,28 @@ def activity_pass_privacy_level(context, routing):
|
||||||
"instance",
|
"instance",
|
||||||
]:
|
]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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
|
# We do not consider music metadata has private
|
||||||
if object_type in MUSIC_OBJECT_TYPE:
|
if object_type in MUSIC_OBJECT_TYPE:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if object and obj_privacy_level and obj_privacy_level in ["me", "instance"]:
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -348,8 +374,16 @@ class OutboxRouter(Router):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not activity_pass_privacy_level(context, routing):
|
if activity_pass_user_privacy_level(context, routing) is False:
|
||||||
logger.info("[federation] Discarding outbox dispatch due to privacy_level")
|
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
|
return
|
||||||
|
|
||||||
for route, handler in self.routes:
|
for route, handler in self.routes:
|
||||||
|
@ -435,6 +469,7 @@ class OutboxRouter(Router):
|
||||||
)
|
)
|
||||||
|
|
||||||
for a in activities:
|
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)
|
funkwhale_utils.on_commit(tasks.dispatch_outbox.delay, activity_id=a.pk)
|
||||||
return activities
|
return activities
|
||||||
|
|
||||||
|
|
|
@ -295,6 +295,8 @@ CONTEXTS = [
|
||||||
"Artist": "fw:Artist",
|
"Artist": "fw:Artist",
|
||||||
"ArtistCredit": "fw:ArtistCredit",
|
"ArtistCredit": "fw:ArtistCredit",
|
||||||
"Library": "fw:Library",
|
"Library": "fw:Library",
|
||||||
|
"Playlist": "fw:Playlist",
|
||||||
|
"PlaylistTrack": "fw:PlaylistTrack",
|
||||||
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
|
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
|
||||||
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
|
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
|
||||||
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
|
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
|
||||||
|
@ -319,6 +321,7 @@ CONTEXTS = [
|
||||||
"copyright": "fw:copyright",
|
"copyright": "fw:copyright",
|
||||||
"category": "schema:category",
|
"category": "schema:category",
|
||||||
"language": "schema:inLanguage",
|
"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.favorites import models as favorites_models
|
||||||
from funkwhale_api.history import models as history_models
|
from funkwhale_api.history import models as history_models
|
||||||
from funkwhale_api.music import models as music_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
|
from . import activity, actors, models, serializers
|
||||||
|
|
||||||
|
@ -678,9 +679,6 @@ def inbox_delete_favorite(payload, context):
|
||||||
favorite.delete()
|
favorite.delete()
|
||||||
|
|
||||||
|
|
||||||
# to do : test listening routes and broadcast
|
|
||||||
|
|
||||||
|
|
||||||
@outbox.register({"type": "Listen", "object.type": "Track"})
|
@outbox.register({"type": "Listen", "object.type": "Track"})
|
||||||
def outbox_create_listening(context):
|
def outbox_create_listening(context):
|
||||||
track = context["track"]
|
track = context["track"]
|
||||||
|
@ -740,3 +738,104 @@ def inbox_delete_listening(payload, context):
|
||||||
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
|
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
|
||||||
return
|
return
|
||||||
favorite.delete()
|
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 licenses
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.music import tasks as music_tasks
|
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
|
from funkwhale_api.tags import models as tags_models
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -972,7 +973,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
|
||||||
first = common_utils.set_query_parameter(conf["id"], page=1)
|
first = common_utils.set_query_parameter(conf["id"], page=1)
|
||||||
current = first
|
current = first
|
||||||
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
|
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
|
||||||
d = {
|
data = {
|
||||||
"id": conf["id"],
|
"id": conf["id"],
|
||||||
"attributedTo": conf["actor"].fid,
|
"attributedTo": conf["actor"].fid,
|
||||||
"totalItems": paginator.count,
|
"totalItems": paginator.count,
|
||||||
|
@ -981,10 +982,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
|
||||||
"first": first,
|
"first": first,
|
||||||
"last": last,
|
"last": last,
|
||||||
}
|
}
|
||||||
d.update(get_additional_fields(conf))
|
data.update(get_additional_fields(conf))
|
||||||
if self.context.get("include_ap_context", True):
|
if self.context.get("include_ap_context", True):
|
||||||
d["@context"] = jsonld.get_default_context()
|
data["@context"] = jsonld.get_default_context()
|
||||||
return d
|
return data
|
||||||
|
|
||||||
|
|
||||||
class LibrarySerializer(PaginatedCollectionSerializer):
|
class LibrarySerializer(PaginatedCollectionSerializer):
|
||||||
|
@ -2241,3 +2242,178 @@ class ListeningSerializer(jsonld.JsonLdSerializer):
|
||||||
actor=actor,
|
actor=actor,
|
||||||
track=track,
|
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
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.db.models.deletion import Collector
|
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.common import utils as common_utils
|
||||||
from funkwhale_api.moderation import mrf
|
from funkwhale_api.moderation import mrf
|
||||||
from funkwhale_api.music import models as music_models
|
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 funkwhale_api.taskapp import celery
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
@ -665,3 +667,14 @@ def check_single_remote_instance_availability(domain):
|
||||||
domain.reachable = False
|
domain.reachable = False
|
||||||
domain.save()
|
domain.save()
|
||||||
return domain.reachable
|
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"tracks", views.MusicTrackViewSet, "tracks")
|
||||||
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
|
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
|
||||||
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
|
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
|
||||||
|
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
|
||||||
|
|
||||||
index_router.register(r"index", views.IndexViewSet, "index")
|
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.moderation import models as moderation_models
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
from funkwhale_api.playlists import models as playlists_models
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
activity,
|
activity,
|
||||||
|
@ -703,3 +704,34 @@ class ListeningsViewSet(
|
||||||
|
|
||||||
serializer = self.get_serializer(instance)
|
serializer = self.get_serializer(instance)
|
||||||
return response.Response(serializer.data)
|
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.Album: lambda t: t.attributed_to,
|
||||||
music_models.Track: lambda t: t.attributed_to,
|
music_models.Track: lambda t: t.attributed_to,
|
||||||
music_models.Library: lambda t: t.actor,
|
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,
|
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.federation import factories as federation_factories
|
||||||
from funkwhale_api.history import factories as history_factories
|
from funkwhale_api.history import factories as history_factories
|
||||||
from funkwhale_api.music import factories as music_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
|
from funkwhale_api.users import serializers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -59,6 +60,15 @@ def create_data(count=2, super_user_name=None):
|
||||||
)
|
)
|
||||||
print("Created fid", upload.track.fid)
|
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__":
|
if __name__ == "__main__":
|
||||||
create_data()
|
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):
|
def get_or_create_playlist(self, playlist_name, user, **options):
|
||||||
playlist = playlist_models.Playlist.objects.filter(
|
playlist = playlist_models.Playlist.objects.filter(
|
||||||
Q(user=user) & Q(name=playlist_name)
|
Q(actor=user.actor) & Q(name=playlist_name)
|
||||||
).first()
|
).first()
|
||||||
if not playlist:
|
if not playlist:
|
||||||
if options["no_dry_run"]:
|
if options["no_dry_run"]:
|
||||||
playlist = playlist_models.Playlist.objects.create(
|
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
|
return playlist
|
||||||
|
|
||||||
|
@ -26,7 +28,9 @@ def get_or_create_playlist(self, playlist_name, user, **options):
|
||||||
)
|
)
|
||||||
if response.lower() in "yes":
|
if response.lower() in "yes":
|
||||||
playlist = playlist_models.Playlist.objects.create(
|
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
|
return playlist
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -5,7 +5,7 @@ from . import models
|
||||||
|
|
||||||
@admin.register(models.Playlist)
|
@admin.register(models.Playlist)
|
||||||
class PlaylistAdmin(admin.ModelAdmin):
|
class PlaylistAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "user", "privacy_level", "creation_date"]
|
list_display = ["name", "actor", "privacy_level", "creation_date"]
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,58 @@
|
||||||
import factory
|
import factory
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from funkwhale_api.factories import NoUpdateOnCreate, registry
|
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.music.factories import TrackFactory
|
||||||
from funkwhale_api.users.factories import UserFactory
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
name = factory.Faker("name")
|
name = factory.Faker("name")
|
||||||
user = factory.SubFactory(UserFactory)
|
actor = factory.SubFactory(ActorFactory)
|
||||||
|
fid = factory.Faker("federation_url")
|
||||||
|
uuid = factory.Faker("uuid4")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "playlists.Playlist"
|
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
|
@registry.register
|
||||||
class PlaylistTrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
class PlaylistTrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
playlist = factory.SubFactory(PlaylistFactory)
|
playlist = factory.SubFactory(PlaylistFactory)
|
||||||
track = factory.SubFactory(TrackFactory)
|
track = factory.SubFactory(TrackFactory)
|
||||||
|
fid = factory.Faker("federation_url")
|
||||||
|
uuid = factory.Faker("uuid4")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "playlists.PlaylistTrack"
|
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(),
|
queryset=music_models.Artist.objects.all(),
|
||||||
distinct=True,
|
distinct=True,
|
||||||
)
|
)
|
||||||
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
|
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Playlist
|
model = models.Playlist
|
||||||
|
@ -42,5 +42,5 @@ class PlaylistFilter(filters.FilterSet):
|
||||||
return queryset.filter(plts_count=0)
|
return queryset.filter(plts_count=0)
|
||||||
|
|
||||||
def filter_q(self, queryset, name, value):
|
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)
|
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 import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.expressions import OuterRef, Subquery
|
from django.db.models.expressions import OuterRef, Subquery
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import exceptions
|
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
|
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):
|
def with_tracks_count(self):
|
||||||
return self.annotate(_tracks_count=models.Count("playlist_tracks"))
|
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()
|
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)
|
name = models.CharField(max_length=50)
|
||||||
user = models.ForeignKey(
|
actor = models.ForeignKey(
|
||||||
"users.User", related_name="playlists", on_delete=models.CASCADE
|
"federation.Actor", related_name="playlists", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
modification_date = models.DateTimeField(auto_now=True)
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
privacy_level = fields.get_privacy_field()
|
privacy_level = fields.get_privacy_field()
|
||||||
|
|
||||||
objects = PlaylistQuerySet.as_manager()
|
objects = PlaylistQuerySet.as_manager()
|
||||||
|
federation_namespace = "playlists"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -84,6 +95,22 @@ class Playlist(models.Model):
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return f"/library/playlists/{self.pk}"
|
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
|
@transaction.atomic
|
||||||
def insert(self, plt, index=None, allow_duplicates=True):
|
def insert(self, plt, index=None, allow_duplicates=True):
|
||||||
"""
|
"""
|
||||||
|
@ -159,9 +186,20 @@ class Playlist(models.Model):
|
||||||
|
|
||||||
self.save(update_fields=["modification_date"])
|
self.save(update_fields=["modification_date"])
|
||||||
start = total
|
start = total
|
||||||
|
|
||||||
plts = [
|
plts = [
|
||||||
PlaylistTrack(
|
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)
|
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):
|
def for_nested_serialization(self, actor=None):
|
||||||
tracks = music_models.Track.objects.with_playable_uploads(actor)
|
tracks = music_models.Track.objects.with_playable_uploads(actor)
|
||||||
tracks = tracks.prefetch_related(
|
tracks = tracks.prefetch_related(
|
||||||
|
@ -228,7 +303,8 @@ class PlaylistTrackQuerySet(models.QuerySet):
|
||||||
return PlaylistTrack.objects.get(pk=plt_id)
|
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(
|
track = models.ForeignKey(
|
||||||
"music.Track", related_name="playlist_tracks", on_delete=models.CASCADE
|
"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)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
objects = PlaylistTrackQuerySet.as_manager()
|
objects = PlaylistTrackQuerySet.as_manager()
|
||||||
|
federation_namespace = "playlist-tracks"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("-playlist", "index")
|
ordering = ("-playlist", "index")
|
||||||
|
@ -251,3 +328,34 @@ class PlaylistTrack(models.Model):
|
||||||
if index is not None and update_indexes:
|
if index is not None and update_indexes:
|
||||||
playlist.remove(index)
|
playlist.remove(index)
|
||||||
return r
|
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 drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
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 import tasks
|
||||||
from funkwhale_api.music.models import Album, Artist, Track
|
from funkwhale_api.music.models import Album, Artist, Track
|
||||||
from funkwhale_api.music.serializers import TrackSerializer
|
from funkwhale_api.music.serializers import TrackSerializer
|
||||||
from funkwhale_api.users.serializers import UserBasicSerializer
|
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -33,16 +32,15 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
||||||
tracks_count = serializers.SerializerMethodField(read_only=True)
|
tracks_count = serializers.SerializerMethodField(read_only=True)
|
||||||
duration = serializers.SerializerMethodField(read_only=True)
|
duration = serializers.SerializerMethodField(read_only=True)
|
||||||
album_covers = serializers.SerializerMethodField(read_only=True)
|
album_covers = serializers.SerializerMethodField(read_only=True)
|
||||||
user = UserBasicSerializer(read_only=True)
|
|
||||||
is_playable = serializers.SerializerMethodField()
|
is_playable = serializers.SerializerMethodField()
|
||||||
actor = serializers.SerializerMethodField()
|
actor = APIActorSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Playlist
|
model = models.Playlist
|
||||||
fields = (
|
fields = (
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"user",
|
"actor",
|
||||||
"modification_date",
|
"modification_date",
|
||||||
"creation_date",
|
"creation_date",
|
||||||
"privacy_level",
|
"privacy_level",
|
||||||
|
@ -54,25 +52,12 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
read_only_fields = ["id", "modification_date", "creation_date"]
|
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)
|
@extend_schema_field(OpenApiTypes.BOOL)
|
||||||
def get_is_playable(self, obj):
|
def get_is_playable(self, obj):
|
||||||
try:
|
return getattr(obj, "is_playable_by_actor", False)
|
||||||
return bool(obj.playable_plts)
|
|
||||||
except AttributeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_tracks_count(self, obj) -> int:
|
def get_tracks_count(self, obj) -> int:
|
||||||
try:
|
return getattr(obj, "tracks_count", obj.playlist_tracks.count())
|
||||||
return obj.tracks_count
|
|
||||||
except AttributeError:
|
|
||||||
# no annotation?
|
|
||||||
return obj.playlist_tracks.count()
|
|
||||||
|
|
||||||
def get_duration(self, obj) -> int:
|
def get_duration(self, obj) -> int:
|
||||||
try:
|
try:
|
||||||
|
@ -173,7 +158,7 @@ class XspfSerializer(serializers.Serializer):
|
||||||
pl = models.Playlist.objects.create(
|
pl = models.Playlist.objects.create(
|
||||||
name=validated_data["title"],
|
name=validated_data["title"],
|
||||||
privacy_level="private",
|
privacy_level="private",
|
||||||
user=validated_data["request"].user,
|
actor=validated_data["request"].user.actor,
|
||||||
)
|
)
|
||||||
pl.insert_many(validated_data["tracks"])
|
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 import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from drf_spectacular.utils import extend_schema
|
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.decorators import action
|
||||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from funkwhale_api.common import fields, permissions
|
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 models as music_models
|
||||||
from funkwhale_api.music import serializers as music_serializers
|
from funkwhale_api.music import serializers as music_serializers
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
@ -31,7 +32,7 @@ class PlaylistViewSet(
|
||||||
serializer_class = serializers.PlaylistSerializer
|
serializer_class = serializers.PlaylistSerializer
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Playlist.objects.all()
|
models.Playlist.objects.all()
|
||||||
.select_related("user__actor__attachment_icon")
|
.select_related("actor__attachment_icon")
|
||||||
.annotate(tracks_count=Count("playlist_tracks", distinct=True))
|
.annotate(tracks_count=Count("playlist_tracks", distinct=True))
|
||||||
.with_covers()
|
.with_covers()
|
||||||
.with_duration()
|
.with_duration()
|
||||||
|
@ -43,30 +44,12 @@ class PlaylistViewSet(
|
||||||
required_scope = "playlists"
|
required_scope = "playlists"
|
||||||
anonymous_policy = "setting"
|
anonymous_policy = "setting"
|
||||||
owner_checks = ["write"]
|
owner_checks = ["write"]
|
||||||
|
owner_field = "actor.user"
|
||||||
filterset_class = filters.PlaylistFilter
|
filterset_class = filters.PlaylistFilter
|
||||||
ordering_fields = ("id", "name", "creation_date", "modification_date")
|
ordering_fields = ("id", "name", "creation_date", "modification_date")
|
||||||
parser_classes = [parsers.XspfParser, JSONParser, FormParser, MultiPartParser]
|
parser_classes = [parsers.XspfParser, JSONParser, FormParser, MultiPartParser]
|
||||||
renderer_classes = [JSONRenderer, renderers.PlaylistXspfRenderer]
|
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):
|
def update(self, request, *args, **kwargs):
|
||||||
playlist = self.get_object()
|
playlist = self.get_object()
|
||||||
content_type = request.headers.get("Content-Type")
|
content_type = request.headers.get("Content-Type")
|
||||||
|
@ -87,8 +70,56 @@ class PlaylistViewSet(
|
||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
pl = serializer.save()
|
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 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))
|
@extend_schema(responses=serializers.PlaylistTrackSerializer(many=True))
|
||||||
@action(methods=["get"], detail=True)
|
@action(methods=["get"], detail=True)
|
||||||
|
@ -126,6 +157,7 @@ class PlaylistViewSet(
|
||||||
)
|
)
|
||||||
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||||
data = {"count": len(plts), "results": serializer.data}
|
data = {"count": len(plts), "results": serializer.data}
|
||||||
|
playlist.schedule_scan(playlist.actor, force=True)
|
||||||
return Response(data, status=201)
|
return Response(data, status=201)
|
||||||
|
|
||||||
@extend_schema(operation_id="clear_playlist")
|
@extend_schema(operation_id="clear_playlist")
|
||||||
|
@ -135,16 +167,19 @@ class PlaylistViewSet(
|
||||||
playlist = self.get_object()
|
playlist = self.get_object()
|
||||||
playlist.playlist_tracks.all().delete()
|
playlist.playlist_tracks.all().delete()
|
||||||
playlist.save(update_fields=["modification_date"])
|
playlist.save(update_fields=["modification_date"])
|
||||||
|
playlist.schedule_scan(playlist.actor)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(
|
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))
|
).with_playable_plts(music_utils.get_actor_from_request(self.request))
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
return serializer.save(
|
return serializer.save(
|
||||||
user=self.request.user,
|
actor=self.request.user.actor,
|
||||||
privacy_level=serializer.validated_data.get(
|
privacy_level=serializer.validated_data.get(
|
||||||
"privacy_level", self.request.user.privacy_level
|
"privacy_level", self.request.user.privacy_level
|
||||||
),
|
),
|
||||||
|
@ -166,7 +201,7 @@ class PlaylistViewSet(
|
||||||
except models.PlaylistTrack.DoesNotExist:
|
except models.PlaylistTrack.DoesNotExist:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
plt.delete(update_indexes=True)
|
plt.delete(update_indexes=True)
|
||||||
|
plt.playlist.schedule_scan(playlist.actor)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@extend_schema(operation_id="reorder_track_in_playlist")
|
@extend_schema(operation_id="reorder_track_in_playlist")
|
||||||
|
@ -191,6 +226,7 @@ class PlaylistViewSet(
|
||||||
except models.PlaylistTrack.DoesNotExist:
|
except models.PlaylistTrack.DoesNotExist:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
playlist.insert(plt, to_index)
|
playlist.insert(plt, to_index)
|
||||||
|
plt.playlist.schedule_scan(playlist.actor)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@extend_schema(operation_id="get_playlist_albums")
|
@extend_schema(operation_id="get_playlist_albums")
|
||||||
|
|
|
@ -254,7 +254,7 @@ def get_playlist_data(playlist):
|
||||||
return {
|
return {
|
||||||
"id": playlist.pk,
|
"id": playlist.pk,
|
||||||
"name": playlist.name,
|
"name": playlist.name,
|
||||||
"owner": playlist.user.username,
|
"owner": playlist.actor.user.username,
|
||||||
"public": "false",
|
"public": "false",
|
||||||
"songCount": playlist._tracks_count,
|
"songCount": playlist._tracks_count,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
|
|
|
@ -100,9 +100,9 @@ def find_object(
|
||||||
|
|
||||||
def get_playlist_qs(request):
|
def get_playlist_qs(request):
|
||||||
qs = playlists_models.Playlist.objects.filter(
|
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")
|
return qs.order_by("-creation_date")
|
||||||
|
|
||||||
|
|
||||||
|
@ -627,7 +627,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
url_name="update_playlist",
|
url_name="update_playlist",
|
||||||
url_path="updatePlaylist",
|
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):
|
def update_playlist(self, request, *args, **kwargs):
|
||||||
playlist = kwargs.pop("obj")
|
playlist = kwargs.pop("obj")
|
||||||
data = request.GET or request.POST
|
data = request.GET or request.POST
|
||||||
|
@ -672,7 +672,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
url_name="delete_playlist",
|
url_name="delete_playlist",
|
||||||
url_path="deletePlaylist",
|
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):
|
def delete_playlist(self, request, *args, **kwargs):
|
||||||
playlist = kwargs.pop("obj")
|
playlist = kwargs.pop("obj")
|
||||||
playlist.delete()
|
playlist.delete()
|
||||||
|
@ -700,7 +700,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if playListId:
|
if playListId:
|
||||||
playlist = request.user.playlists.get(pk=playListId)
|
playlist = request.user.actor.playlists.get(pk=playListId)
|
||||||
createPlaylist = False
|
createPlaylist = False
|
||||||
if not name and not playlist:
|
if not name and not playlist:
|
||||||
return response.Response(
|
return response.Response(
|
||||||
|
@ -712,7 +712,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if createPlaylist:
|
if createPlaylist:
|
||||||
playlist = request.user.playlists.create(name=name)
|
playlist = request.user.actor.playlists.create(name=name)
|
||||||
ids = []
|
ids = []
|
||||||
for i in data.getlist("songId"):
|
for i in data.getlist("songId"):
|
||||||
try:
|
try:
|
||||||
|
@ -731,7 +731,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
|
||||||
pass
|
pass
|
||||||
if sorted_tracks:
|
if sorted_tracks:
|
||||||
playlist.insert_many(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)}
|
data = {"playlist": serializers.get_playlist_detail_data(playlist)}
|
||||||
return response.Response(data)
|
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):
|
def test_owner_permission_owner_field_ok(nodb_factories, api_request):
|
||||||
playlist = nodb_factories["playlists.Playlist"]()
|
playlist = nodb_factories["playlists.Playlist"]()
|
||||||
|
nodb_factories["users.User"](actor=playlist.actor)
|
||||||
view = APIView.as_view()
|
view = APIView.as_view()
|
||||||
permission = permissions.OwnerPermission()
|
permission = permissions.OwnerPermission()
|
||||||
request = api_request.get("/")
|
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)
|
check = permission.has_object_permission(request, view, playlist)
|
||||||
|
|
||||||
assert check is True
|
assert check is True
|
||||||
|
@ -24,7 +26,7 @@ def test_owner_permission_owner_field_not_ok(
|
||||||
permission = permissions.OwnerPermission()
|
permission = permissions.OwnerPermission()
|
||||||
request = api_request.get("/")
|
request = api_request.get("/")
|
||||||
setattr(request, "user", anonymous_user)
|
setattr(request, "user", anonymous_user)
|
||||||
|
setattr(view, "owner_field", "actor.user")
|
||||||
with pytest.raises(Http404):
|
with pytest.raises(Http404):
|
||||||
permission.has_object_permission(request, view, playlist)
|
permission.has_object_permission(request, view, playlist)
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from funkwhale_api.favorites import models as favorites_models
|
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.history import models as history_models
|
||||||
from funkwhale_api.moderation import serializers as moderation_serializers
|
from funkwhale_api.moderation import serializers as moderation_serializers
|
||||||
|
from funkwhale_api.playlists import models as playlists_models
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
@ -21,6 +24,10 @@ from funkwhale_api.moderation import serializers as moderation_serializers
|
||||||
({"type": "Accept"}, routes.inbox_accept),
|
({"type": "Accept"}, routes.inbox_accept),
|
||||||
({"type": "Reject"}, routes.inbox_reject_follow),
|
({"type": "Reject"}, routes.inbox_reject_follow),
|
||||||
({"type": "Create", "object": {"type": "Audio"}}, routes.inbox_create_audio),
|
({"type": "Create", "object": {"type": "Audio"}}, routes.inbox_create_audio),
|
||||||
|
(
|
||||||
|
{"type": "Create", "object": {"type": "Playlist"}},
|
||||||
|
routes.inbox_create_playlist,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
{"type": "Update", "object": {"type": "Library"}},
|
{"type": "Update", "object": {"type": "Library"}},
|
||||||
routes.inbox_update_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": "Audio"}}, routes.inbox_delete_audio),
|
||||||
({"type": "Delete", "object": {"type": "Album"}}, routes.inbox_delete_album),
|
({"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": "Undo", "object": {"type": "Follow"}}, routes.inbox_undo_follow),
|
||||||
({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist),
|
({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist),
|
||||||
({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album),
|
({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album),
|
||||||
({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track),
|
({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track),
|
||||||
({"type": "Update", "object": {"type": "Audio"}}, routes.inbox_update_audio),
|
({"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": "Person"}}, routes.inbox_delete_actor),
|
||||||
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
|
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
|
||||||
({"type": "Flag"}, routes.inbox_flag),
|
({"type": "Flag"}, routes.inbox_flag),
|
||||||
|
@ -61,6 +76,10 @@ def test_inbox_routes(route, handler):
|
||||||
({"type": "Follow"}, routes.outbox_follow),
|
({"type": "Follow"}, routes.outbox_follow),
|
||||||
({"type": "Reject"}, routes.outbox_reject_follow),
|
({"type": "Reject"}, routes.outbox_reject_follow),
|
||||||
({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio),
|
({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio),
|
||||||
|
(
|
||||||
|
{"type": "Create", "object": {"type": "Playlist"}},
|
||||||
|
routes.outbox_create_playlist,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
{"type": "Update", "object": {"type": "Library"}},
|
{"type": "Update", "object": {"type": "Library"}},
|
||||||
routes.outbox_update_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": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow),
|
||||||
({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track),
|
({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track),
|
||||||
({"type": "Update", "object": {"type": "Audio"}}, routes.outbox_update_audio),
|
({"type": "Update", "object": {"type": "Audio"}}, routes.outbox_update_audio),
|
||||||
|
(
|
||||||
|
{"type": "Update", "object": {"type": "Playlist"}},
|
||||||
|
routes.outbox_update_playlist,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
{"type": "Delete", "object": {"type": "Tombstone"}},
|
{"type": "Delete", "object": {"type": "Tombstone"}},
|
||||||
routes.outbox_delete_actor,
|
routes.outbox_delete_actor,
|
||||||
|
@ -93,6 +116,10 @@ def test_inbox_routes(route, handler):
|
||||||
{"type": "Like", "object": {"type": "Track"}},
|
{"type": "Like", "object": {"type": "Track"}},
|
||||||
routes.outbox_create_track_favorite,
|
routes.outbox_create_track_favorite,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
{"type": "Delete", "object": {"type": "Playlist"}},
|
||||||
|
routes.outbox_delete_playlist,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_outbox_routes(route, handler):
|
def test_outbox_routes(route, handler):
|
||||||
|
@ -1135,4 +1162,121 @@ def test_inbox_create_listening(factories, mocker):
|
||||||
).exists()
|
).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.common import utils
|
||||||
from funkwhale_api.federation import actors, serializers
|
from funkwhale_api.federation import actors, serializers
|
||||||
from funkwhale_api.federation import utils as federation_utils
|
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(
|
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)
|
response = logged_in_api_client.get(url)
|
||||||
assert response.status_code == expected
|
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.Album", {"attributed": True}, "attributed_to"),
|
||||||
("music.Track", {"attributed": True}, "attributed_to"),
|
("music.Track", {"attributed": True}, "attributed_to"),
|
||||||
("music.Library", {}, "actor"),
|
("music.Library", {}, "actor"),
|
||||||
("playlists.Playlist", {"user__with_actor": True}, "user.actor"),
|
("playlists.Playlist", {}, "actor"),
|
||||||
("federation.Actor", {}, "self"),
|
("federation.Actor", {}, "self"),
|
||||||
("audio.Channel", {}, "attributed_to"),
|
("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):
|
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()
|
c = create_playlist_from_folder_structure.Command()
|
||||||
options = {
|
options = {
|
||||||
"dir_name": DATA_DIR,
|
"dir_name": DATA_DIR,
|
||||||
|
|
|
@ -3,7 +3,7 @@ from rest_framework import exceptions
|
||||||
|
|
||||||
|
|
||||||
def test_can_insert_plt(factories):
|
def test_can_insert_plt(factories):
|
||||||
plt = factories["playlists.PlaylistTrack"]()
|
plt = factories["playlists.PlaylistTrack"](index=None)
|
||||||
modification_date = plt.playlist.modification_date
|
modification_date = plt.playlist.modification_date
|
||||||
|
|
||||||
assert plt.index is None
|
assert plt.index is None
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from funkwhale_api.federation import serializers as federation_serializers
|
from funkwhale_api.federation import serializers as federation_serializers
|
||||||
from funkwhale_api.playlists import 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):
|
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):
|
def test_playlist_serializer(factories, to_api_date):
|
||||||
playlist = factories["playlists.Playlist"]()
|
playlist = factories["playlists.Playlist"]()
|
||||||
actor = playlist.user.create_actor()
|
actor = playlist.actor
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
"id": playlist.pk,
|
"id": playlist.pk,
|
||||||
"name": playlist.name,
|
"name": playlist.name,
|
||||||
"privacy_level": playlist.privacy_level,
|
"privacy_level": playlist.privacy_level,
|
||||||
"is_playable": None,
|
"is_playable": False,
|
||||||
"creation_date": to_api_date(playlist.creation_date),
|
"creation_date": to_api_date(playlist.creation_date),
|
||||||
"modification_date": to_api_date(playlist.modification_date),
|
"modification_date": to_api_date(playlist.modification_date),
|
||||||
"actor": federation_serializers.APIActorSerializer(actor).data,
|
"actor": federation_serializers.APIActorSerializer(actor).data,
|
||||||
"user": users_serializers.UserBasicSerializer(playlist.user).data,
|
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
"tracks_count": 0,
|
"tracks_count": 0,
|
||||||
"album_covers": [],
|
"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):
|
def test_can_get_playlist_list(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
logged_in_api_client.user.create_actor()
|
||||||
factories["playlists.Playlist"].create_batch(5)
|
factories["playlists.Playlist"].create_batch(5)
|
||||||
url = reverse("api:v2:playlists:playlists-list")
|
url = reverse("api:v2:playlists-list")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = logged_in_api_client.get(url, headers=headers)
|
response = logged_in_api_client.get(url, headers=headers)
|
||||||
data = json.loads(response.content)
|
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)
|
||||||
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"}
|
headers = {"Accept": "application/octet-stream"}
|
||||||
response = logged_in_api_client.get(url, headers=headers)
|
response = logged_in_api_client.get(url, headers=headers)
|
||||||
el = etree.fromstring(response.content)
|
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):
|
def test_can_get_playlists_json(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
logged_in_api_client.user.create_actor()
|
||||||
pl = factories["playlists.Playlist"]()
|
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")
|
response = logged_in_api_client.get(url, format="json")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data["name"] == pl.name
|
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):
|
def test_can_get_user_playlists_list(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
logged_in_api_client.user.create_actor()
|
||||||
user = factories["users.User"]()
|
user = factories["users.User"](with_actor=True)
|
||||||
factories["playlists.Playlist"](user=user)
|
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"
|
url = resolve_url(url) + "?user=me"
|
||||||
response = logged_in_api_client.get(url)
|
response = logged_in_api_client.get(url)
|
||||||
data = json.loads(response.content.decode("utf-8"))
|
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):
|
def test_can_post_user_playlists(logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
logged_in_api_client.user.create_actor()
|
||||||
playlist = {"name": "Les chiennes de l'hexagone", "privacy_level": "me"}
|
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")
|
response = logged_in_api_client.post(url, playlist, format="json")
|
||||||
data = json.loads(response.content.decode("utf-8"))
|
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"](
|
factories["music.Track"](
|
||||||
title="Opinel 12", artist_credit__artist=artist, album=album
|
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()
|
data = open("./tests/playlists/test.xspf", "rb").read()
|
||||||
response = logged_in_api_client.post(url, data=data, format="xspf")
|
response = logged_in_api_client.post(url, data=data, format="xspf")
|
||||||
data = json.loads(response.content)
|
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):
|
def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
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()
|
data = open("./tests/playlists/test.xspf", "rb").read()
|
||||||
response = logged_in_api_client.post(url, data=data, format="xspf")
|
response = logged_in_api_client.post(url, data=data, format="xspf")
|
||||||
data = json.loads(response.content)
|
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):
|
def test_can_patch_playlists_octet_stream(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
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")
|
artist = factories["music.Artist"](name="Davinhor")
|
||||||
album = factories["music.Album"](
|
album = factories["music.Album"](
|
||||||
title="Racisme en pls", artist_credit__artist=artist
|
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"](
|
track = factories["music.Track"](
|
||||||
title="Opinel 12", artist_credit__artist=artist, album=album
|
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()
|
data = open("./tests/playlists/test.xspf", "rb").read()
|
||||||
response = logged_in_api_client.patch(url, data=data, format="xspf")
|
response = logged_in_api_client.patch(url, data=data, format="xspf")
|
||||||
pl.refresh_from_db()
|
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()
|
logged_in_api_client.user.create_actor()
|
||||||
pl = factories["playlists.Playlist"]()
|
pl = factories["playlists.Playlist"]()
|
||||||
plt = factories["playlists.PlaylistTrack"](playlist=pl)
|
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)
|
response = logged_in_api_client.get(url)
|
||||||
data = json.loads(response.content.decode("utf-8"))
|
data = json.loads(response.content.decode("utf-8"))
|
||||||
assert response.status_code == 200
|
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()
|
logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"]()
|
playlist = factories["playlists.Playlist"]()
|
||||||
plt = factories["playlists.PlaylistTrack"](playlist=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)
|
response = logged_in_api_client.get(url)
|
||||||
data = json.loads(response.content)
|
data = json.loads(response.content)
|
||||||
assert response.status_code == 200
|
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()
|
logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"]()
|
playlist = factories["playlists.Playlist"]()
|
||||||
plt = factories["playlists.PlaylistTrack"](playlist=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)
|
response = logged_in_api_client.get(url)
|
||||||
data = json.loads(response.content)
|
data = json.loads(response.content)
|
||||||
assert response.status_code == 200
|
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):
|
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")
|
url = reverse("api:v1:playlists-list")
|
||||||
data = {"name": "test", "privacy_level": "everyone"}
|
data = {"name": "test", "privacy_level": "everyone"}
|
||||||
|
|
||||||
logged_in_api_client.post(url, data)
|
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.name == "test"
|
||||||
|
assert playlist.actor == actor
|
||||||
assert playlist.privacy_level == "everyone"
|
assert playlist.privacy_level == "everyone"
|
||||||
|
|
||||||
|
|
||||||
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
|
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"]()
|
playlist = factories["playlists.Playlist"](actor=actor)
|
||||||
factories["playlists.PlaylistTrack"](playlist=playlist)
|
factories["playlists.PlaylistTrack"](playlist=playlist)
|
||||||
|
|
||||||
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
|
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
|
||||||
response = logged_in_api_client.get(url, content_type="application/json")
|
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):
|
def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"]()
|
playlist = factories["playlists.Playlist"](privacy_level="everyone")
|
||||||
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
|
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
|
||||||
factories["music.Upload"].create_batch(
|
factories["music.Upload"].create_batch(
|
||||||
3, track=plt.track, library__privacy_level="everyone", import_status="finished"
|
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):
|
def test_playlist_inherits_user_privacy(logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
|
||||||
url = reverse("api:v1:playlists-list")
|
url = reverse("api:v1:playlists-list")
|
||||||
user = logged_in_api_client.user
|
user = logged_in_api_client.user
|
||||||
|
user.create_actor()
|
||||||
user.privacy_level = "me"
|
user.privacy_level = "me"
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
data = {"name": "test"}
|
data = {"name": "test"}
|
||||||
|
|
||||||
logged_in_api_client.post(url, data)
|
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
|
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):
|
def test_url_requires_login(name, method, factories, api_client):
|
||||||
url = reverse(name)
|
url = reverse(name)
|
||||||
|
|
||||||
response = getattr(api_client, method)(url, {})
|
response = getattr(api_client, method.lower())(url, {})
|
||||||
|
|
||||||
assert response.status_code == 401
|
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):
|
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")
|
remove = mocker.spy(models.Playlist, "remove")
|
||||||
factories["music.Track"]()
|
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)
|
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
|
||||||
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
|
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
|
||||||
url = reverse("api:v1:playlists-remove", kwargs={"pk": playlist.pk})
|
url = reverse("api:v1:playlists-remove", kwargs={"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(
|
def test_can_add_multiple_tracks_at_once_via_api(
|
||||||
factories, mocker, logged_in_api_client
|
factories, mocker, logged_in_api_client
|
||||||
):
|
):
|
||||||
logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
|
playlist = factories["playlists.Playlist"](actor=actor)
|
||||||
tracks = factories["music.Track"].create_batch(size=5)
|
tracks = factories["music.Track"].create_batch(size=5)
|
||||||
track_ids = [t.id for t in tracks]
|
track_ids = [t.id for t in tracks]
|
||||||
mocker.spy(playlist, "insert_many")
|
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):
|
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
|
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(
|
tracks = factories["music.Track"].create_batch(
|
||||||
size=preferences["playlists__max_tracks"] + 1
|
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):
|
def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
|
playlist = factories["playlists.Playlist"](actor=actor)
|
||||||
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
|
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
|
||||||
url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk})
|
url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk})
|
||||||
response = logged_in_api_client.delete(url)
|
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):
|
def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
|
playlist = factories["playlists.Playlist"](actor=actor)
|
||||||
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
|
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
|
||||||
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
|
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
|
||||||
response = logged_in_api_client.patch(url, {"name": "test"})
|
response = logged_in_api_client.patch(url, {"name": "test"})
|
||||||
playlist.refresh_from_db()
|
playlist.refresh_from_db()
|
||||||
|
|
||||||
assert response.status_code == 200
|
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):
|
def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client):
|
||||||
logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
|
playlist = factories["playlists.Playlist"](actor=actor)
|
||||||
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
|
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
|
||||||
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
|
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
|
||||||
url = reverse("api:v1:playlists-move", kwargs={"pk": playlist.pk})
|
url = reverse("api:v1:playlists-move", kwargs={"pk": playlist.pk})
|
||||||
|
|
|
@ -270,11 +270,12 @@ def test_get_album_list2_serializer(factories):
|
||||||
def test_playlist_serializer(factories):
|
def test_playlist_serializer(factories):
|
||||||
plt = factories["playlists.PlaylistTrack"]()
|
plt = factories["playlists.PlaylistTrack"]()
|
||||||
playlist = plt.playlist
|
playlist = plt.playlist
|
||||||
|
factories["users.User"](actor=playlist.actor)
|
||||||
qs = music_models.Album.objects.with_tracks_count().order_by("pk")
|
qs = music_models.Album.objects.with_tracks_count().order_by("pk")
|
||||||
expected = {
|
expected = {
|
||||||
"id": playlist.pk,
|
"id": playlist.pk,
|
||||||
"name": playlist.name,
|
"name": playlist.name,
|
||||||
"owner": playlist.user.username,
|
"owner": playlist.actor.user.username,
|
||||||
"public": "false",
|
"public": "false",
|
||||||
"songCount": 1,
|
"songCount": 1,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
|
@ -289,11 +290,12 @@ def test_playlist_detail_serializer(factories):
|
||||||
plt = factories["playlists.PlaylistTrack"]()
|
plt = factories["playlists.PlaylistTrack"]()
|
||||||
upload = factories["music.Upload"](track=plt.track)
|
upload = factories["music.Upload"](track=plt.track)
|
||||||
playlist = plt.playlist
|
playlist = plt.playlist
|
||||||
|
factories["users.User"](actor=playlist.actor)
|
||||||
qs = music_models.Album.objects.with_tracks_count().order_by("pk")
|
qs = music_models.Album.objects.with_tracks_count().order_by("pk")
|
||||||
expected = {
|
expected = {
|
||||||
"id": playlist.pk,
|
"id": playlist.pk,
|
||||||
"name": playlist.name,
|
"name": playlist.name,
|
||||||
"owner": playlist.user.username,
|
"owner": playlist.actor.user.username,
|
||||||
"public": "false",
|
"public": "false",
|
||||||
"songCount": 1,
|
"songCount": 1,
|
||||||
"duration": 0,
|
"duration": 0,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import funkwhale_api
|
||||||
from funkwhale_api.moderation import filters as moderation_filters
|
from funkwhale_api.moderation import filters as moderation_filters
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.music import views as music_views
|
from funkwhale_api.music import views as music_views
|
||||||
|
from funkwhale_api.playlists import models
|
||||||
from funkwhale_api.subsonic import renderers, serializers
|
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()
|
logged_in_api_client.user.create_actor()
|
||||||
url = reverse("api:subsonic:subsonic-get_playlists")
|
url = reverse("api:subsonic:subsonic-get_playlists")
|
||||||
assert url.endswith("getPlaylists") is True
|
assert url.endswith("getPlaylists") is True
|
||||||
|
|
||||||
playlist1 = factories["playlists.PlaylistTrack"](
|
playlist1 = factories["playlists.PlaylistTrack"](
|
||||||
playlist__user=logged_in_api_client.user
|
playlist__actor__user=logged_in_api_client.user
|
||||||
).playlist
|
).playlist
|
||||||
playlist2 = factories["playlists.PlaylistTrack"](
|
playlist2 = factories["playlists.PlaylistTrack"](
|
||||||
playlist__privacy_level="everyone"
|
playlist__privacy_level="everyone"
|
||||||
|
@ -658,9 +660,16 @@ def test_get_playlists(f, db, logged_in_api_client, factories):
|
||||||
playlist__privacy_level="instance"
|
playlist__privacy_level="instance"
|
||||||
).playlist
|
).playlist
|
||||||
# private
|
# private
|
||||||
factories["playlists.PlaylistTrack"](playlist__privacy_level="me")
|
plt = factories["playlists.PlaylistTrack"](playlist__privacy_level="me")
|
||||||
# no track
|
# 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})
|
response = logged_in_api_client.get(url, {"f": f})
|
||||||
|
|
||||||
qs = (
|
qs = (
|
||||||
|
@ -681,8 +690,10 @@ def test_get_playlist(f, db, logged_in_api_client, factories):
|
||||||
url = reverse("api:subsonic:subsonic-get_playlist")
|
url = reverse("api:subsonic:subsonic-get_playlist")
|
||||||
assert url.endswith("getPlaylist") is True
|
assert url.endswith("getPlaylist") is True
|
||||||
playlist = factories["playlists.PlaylistTrack"](
|
playlist = factories["playlists.PlaylistTrack"](
|
||||||
playlist__user=logged_in_api_client.user
|
playlist__actor__user=logged_in_api_client.user
|
||||||
).playlist
|
).playlist
|
||||||
|
factories["users.User"](actor=playlist.actor)
|
||||||
|
|
||||||
response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk})
|
response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk})
|
||||||
|
|
||||||
qs = playlist.__class__.objects.with_tracks_count()
|
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):
|
def test_update_playlist(f, db, logged_in_api_client, factories):
|
||||||
url = reverse("api:subsonic:subsonic-update_playlist")
|
url = reverse("api:subsonic:subsonic-update_playlist")
|
||||||
assert url.endswith("updatePlaylist") is True
|
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)
|
factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
|
||||||
new_track = factories["music.Track"]()
|
new_track = factories["music.Track"]()
|
||||||
response = logged_in_api_client.get(
|
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):
|
def test_delete_playlist(f, db, logged_in_api_client, factories):
|
||||||
url = reverse("api:subsonic:subsonic-delete_playlist")
|
url = reverse("api:subsonic:subsonic-delete_playlist")
|
||||||
assert url.endswith("deletePlaylist") is True
|
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})
|
response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
with pytest.raises(playlist.__class__.DoesNotExist):
|
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
|
assert url.endswith("createPlaylist") is True
|
||||||
track1 = factories["music.Track"]()
|
track1 = factories["music.Track"]()
|
||||||
track2 = factories["music.Track"]()
|
track2 = factories["music.Track"]()
|
||||||
|
actor = logged_in_api_client.user.create_actor()
|
||||||
response = logged_in_api_client.get(
|
response = logged_in_api_client.get(
|
||||||
url, {"f": f, "name": "hello", "songId": [track1.pk, track2.pk]}
|
url, {"f": f, "name": "hello", "songId": [track1.pk, track2.pk]}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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
|
assert playlist.playlist_tracks.count() == 2
|
||||||
for i, t in enumerate([track1, track2]):
|
for i, t in enumerate([track1, track2]):
|
||||||
plt = playlist.playlist_tracks.get(track=t)
|
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):
|
def test_create_playlist_with_update(f, db, logged_in_api_client, factories):
|
||||||
url = reverse("api:subsonic:subsonic-create_playlist")
|
url = reverse("api:subsonic:subsonic-create_playlist")
|
||||||
assert url.endswith("createPlaylist") is True
|
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)
|
factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
|
||||||
track1 = factories["music.Track"]()
|
track1 = factories["music.Track"]()
|
||||||
track2 = factories["music.Track"]()
|
track2 = factories["music.Track"]()
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Playlist federation (#1458)
|
|
@ -30,4 +30,8 @@ services:
|
||||||
|
|
||||||
celeryworker:
|
celeryworker:
|
||||||
<<: *django
|
<<: *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 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)
|
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
|
#### Backend
|
||||||
|
|
||||||
Adding a playlist to a library is an ActivityPub `Follow`. The follow request is made to an actor specially created for the playlist.
|
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.
|
||||||
Endpoints and logic should follow the actual ActivityPub implementation :
|
|
||||||
|
|
||||||
- The follow request is accepted automatically if the playlist is public
|
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.
|
||||||
- When accepted, the playlist is added to the local pod, the playlist actor is created has followed by the local actor
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
Add playlist update activities to notifications.
|
|
||||||
|
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
|
#### Frontend
|
||||||
|
|
||||||
|
@ -42,3 +48,9 @@ Add playlist update activities to notifications.
|
||||||
### Minimum Viable Product
|
### Minimum Viable Product
|
||||||
|
|
||||||
### Next Steps
|
### 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>
|
</router-link>
|
||||||
</strong>
|
</strong>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<user-link
|
<actor-link
|
||||||
:user="playlist.user"
|
:actor="playlist.actor"
|
||||||
:avatar="false"
|
:avatar="false"
|
||||||
class="left floated"
|
class="left floated"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -95,7 +95,7 @@ const triggerFileInput = () => {
|
||||||
{{ labels.export }}
|
{{ labels.export }}
|
||||||
</div>
|
</div>
|
||||||
<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"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
:title="t('components.playlists.PlaylistDropdown.button.import.description')"
|
:title="t('components.playlists.PlaylistDropdown.button.import.description')"
|
||||||
|
|
|
@ -3731,7 +3731,7 @@
|
||||||
"header": {
|
"header": {
|
||||||
"accountData": "Account data",
|
"accountData": "Account data",
|
||||||
"activePolicy": "This domain is subject to specific moderation rules",
|
"activePolicy": "This domain is subject to specific moderation rules",
|
||||||
"activity": "Activty",
|
"activity": "Activity",
|
||||||
"audioContent": "Audio content",
|
"audioContent": "Audio content",
|
||||||
"localAccount": "Local account",
|
"localAccount": "Local account",
|
||||||
"noPolicy": "You don't have any rule in place for this account."
|
"noPolicy": "You don't have any rule in place for this account."
|
||||||
|
@ -3818,7 +3818,7 @@
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"activePolicy": "This domain is subject to specific moderation rules",
|
"activePolicy": "This domain is subject to specific moderation rules",
|
||||||
"activity": "Activty",
|
"activity": "Activity",
|
||||||
"audioContent": "Audio content",
|
"audioContent": "Audio content",
|
||||||
"instanceData": "Instance data",
|
"instanceData": "Instance data",
|
||||||
"noPolicy": "You don't have any rule in place for this domain."
|
"noPolicy": "You don't have any rule in place for this domain."
|
||||||
|
|
|
@ -198,7 +198,7 @@ export interface Playlist {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
modification_date: string
|
modification_date: string
|
||||||
user: User
|
actor: Actor
|
||||||
privacy_level: PrivacyLevel
|
privacy_level: PrivacyLevel
|
||||||
tracks_count: number
|
tracks_count: number
|
||||||
duration: number
|
duration: number
|
||||||
|
|
|
@ -95,7 +95,7 @@ const deletePlaylist = async () => {
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ playlist.name }}
|
{{ playlist.name }}
|
||||||
<div class="sub header">
|
<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>
|
<br>
|
||||||
<duration :seconds="playlist.duration" />
|
<duration :seconds="playlist.duration" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,7 +114,7 @@ const deletePlaylist = async () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="ui buttons">
|
<div class="ui buttons">
|
||||||
<button
|
<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"
|
class="ui icon labeled button"
|
||||||
@click="edit = !edit"
|
@click="edit = !edit"
|
||||||
>
|
>
|
||||||
|
@ -137,7 +137,7 @@ const deletePlaylist = async () => {
|
||||||
{{ $t('views.playlists.Detail.button.embed') }}
|
{{ $t('views.playlists.Detail.button.embed') }}
|
||||||
</button>
|
</button>
|
||||||
<dangerous-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"
|
class="ui labeled danger icon button"
|
||||||
:action="deletePlaylist"
|
:action="deletePlaylist"
|
||||||
>
|
>
|
||||||
|
|
Loading…
Reference in New Issue