Use playlists to privately share audio files (#2417)

This commit is contained in:
petitminion 2025-05-07 15:22:38 +00:00
parent 36030c68ee
commit 4549dcb61e
53 changed files with 1171 additions and 394 deletions

View File

@ -31,6 +31,7 @@ def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
}
)
# Federated TrackFavorite don't have an user associated with the trackfavorite.actor
# to do : if we implement the followers privacy_level this will become a problem
no_user_query = models.Q(**{f"{user_field}__isnull": True})
return (

View File

@ -76,11 +76,13 @@ class PrivacyLevelPermission(BasePermission):
# to avoid leaking data (#2326)
return True
privacy_level = (
obj.actor.user.privacy_level
if hasattr(obj, "actor")
else obj.user.privacy_level
)
if hasattr(obj, "privacy_level"):
privacy_level = obj.privacy_level
elif hasattr(obj, "actor") and obj.actor.user:
privacy_level = obj.actor.user.privacy_level
else:
privacy_level = obj.user.privacy_level
obj_actor = obj.actor if hasattr(obj, "actor") else obj.user.actor
if privacy_level == "everyone":
@ -106,9 +108,7 @@ class PrivacyLevelPermission(BasePermission):
elif privacy_level == "me" and obj_actor == request_actor:
return True
elif privacy_level == "followers" and (
request_actor in obj.user.actor.get_approved_followers()
):
elif request_actor in obj_actor.get_approved_followers():
return True
else:
return False

View File

@ -6929,7 +6929,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v1/playlists/{id}/:
/api/v1/playlists/{uuid}/:
get:
operationId: get_playlist
parameters:
@ -6941,10 +6941,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -6972,10 +6972,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7018,10 +7018,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7063,10 +7063,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7076,7 +7076,7 @@ paths:
responses:
'204':
description: No response body
/api/v1/playlists/{id}/add/:
/api/v1/playlists/{uuid}/add/:
post:
operationId: add_to_playlist
parameters:
@ -7088,10 +7088,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7123,7 +7123,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v1/playlists/{id}/albums/:
/api/v1/playlists/{uuid}/albums/:
get:
operationId: get_playlist_albums
parameters:
@ -7135,10 +7135,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7155,9 +7155,9 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v1/playlists/{id}/artists/:
/api/v1/playlists/{uuid}/artists/:
get:
operationId: get_playlist_artits
operationId: get_playlist_artists
parameters:
- in: query
name: format
@ -7167,10 +7167,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7187,7 +7187,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v1/playlists/{id}/clear/:
/api/v1/playlists/{uuid}/clear/:
delete:
operationId: clear_playlist
parameters:
@ -7199,10 +7199,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7212,7 +7212,7 @@ paths:
responses:
'204':
description: No response body
/api/v1/playlists/{id}/move/:
/api/v1/playlists/{uuid}/move/:
post:
operationId: reorder_track_in_playlist
parameters:
@ -7224,10 +7224,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7259,7 +7259,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v1/playlists/{id}/remove/:
/api/v1/playlists/{uuid}/remove/:
post:
operationId: remove_from_playlist_2
parameters:
@ -7271,10 +7271,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7317,10 +7317,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -7330,7 +7330,7 @@ paths:
responses:
'204':
description: No response body
/api/v1/playlists/{id}/tracks/:
/api/v1/playlists/{uuid}/tracks/:
get:
operationId: get_playlist_tracks
parameters:
@ -7349,12 +7349,6 @@ paths:
enum:
- json
- xspf
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this playlist.
required: true
- in: query
name: name
schema:
@ -7397,6 +7391,12 @@ paths:
name: track
schema:
type: integer
- in: path
name: uuid
schema:
type: string
format: uuid
required: true
tags:
- playlists
security:
@ -16474,7 +16474,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v2/playlists/{id}/:
/api/v2/playlists/{uuid}/:
get:
operationId: get_playlist_2
parameters:
@ -16486,10 +16486,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16517,10 +16517,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16563,10 +16563,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16608,10 +16608,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16621,7 +16621,7 @@ paths:
responses:
'204':
description: No response body
/api/v2/playlists/{id}/add/:
/api/v2/playlists/{uuid}/add/:
post:
operationId: add_to_playlist_2
parameters:
@ -16633,10 +16633,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16668,7 +16668,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v2/playlists/{id}/albums/:
/api/v2/playlists/{uuid}/albums/:
get:
operationId: get_playlist_albums_2
parameters:
@ -16680,10 +16680,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16700,9 +16700,9 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v2/playlists/{id}/artists/:
/api/v2/playlists/{uuid}/artists/:
get:
operationId: get_playlist_artits_2
operationId: get_playlist_artists_2
parameters:
- in: query
name: format
@ -16712,10 +16712,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16732,7 +16732,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v2/playlists/{id}/clear/:
/api/v2/playlists/{uuid}/clear/:
delete:
operationId: clear_playlist_2
parameters:
@ -16744,10 +16744,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16757,7 +16757,7 @@ paths:
responses:
'204':
description: No response body
/api/v2/playlists/{id}/move/:
/api/v2/playlists/{uuid}/move/:
post:
operationId: reorder_track_in_playlist_2
parameters:
@ -16769,10 +16769,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16804,7 +16804,7 @@ paths:
schema:
$ref: '#/components/schemas/Playlist'
description: ''
/api/v2/playlists/{id}/remove/:
/api/v2/playlists/{uuid}/remove/:
post:
operationId: remove_from_playlist_4
parameters:
@ -16816,10 +16816,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16862,10 +16862,10 @@ paths:
- json
- xspf
- in: path
name: id
name: uuid
schema:
type: integer
description: A unique integer value identifying this playlist.
type: string
format: uuid
required: true
tags:
- playlists
@ -16875,7 +16875,7 @@ paths:
responses:
'204':
description: No response body
/api/v2/playlists/{id}/tracks/:
/api/v2/playlists/{uuid}/tracks/:
get:
operationId: get_playlist_tracks_2
parameters:
@ -16894,12 +16894,6 @@ paths:
enum:
- json
- xspf
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this playlist.
required: true
- in: query
name: name
schema:
@ -16942,6 +16936,12 @@ paths:
name: track
schema:
type: integer
- in: path
name: uuid
schema:
type: string
format: uuid
required: true
tags:
- playlists
security:
@ -24813,8 +24813,13 @@ components:
Playlist:
type: object
properties:
id:
type: integer
uuid:
type: string
format: uuid
readOnly: true
fid:
type: string
format: uri
readOnly: true
name:
type: string
@ -24851,16 +24856,26 @@ components:
type: string
nullable: true
maxLength: 5000
library:
type: string
format: uri
readOnly: true
library_followed:
type: boolean
readOnly: true
required:
- actor
- album_covers
- creation_date
- duration
- id
- fid
- is_playable
- library
- library_followed
- modification_date
- name
- tracks_count
- uuid
PlaylistAddManyRequest:
type: object
properties:

View File

@ -405,6 +405,7 @@ class Fetch(models.Model):
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
contexts.FW.Playlist: [serializers.PlaylistSerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],

View File

@ -679,9 +679,6 @@ def inbox_delete_favorite(payload, context):
favorite.delete()
# to do : test listening routes and broadcast
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
@ -807,18 +804,19 @@ def inbox_delete_playlist(payload, context):
@inbox.register({"type": "Update", "object.type": "Playlist"})
def inbox_update_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
"""If we receive an update on an unkwnown playlist, we create the playlist"""
if not actor.playlists.filter(fid=playlist_id).exists():
logger.debug("Discarding update of unkwnown playlist_id %s", playlist_id)
return
playlist_id = payload["object"].get("id")
serializer = serializers.PlaylistSerializer(data=payload["object"])
if serializer.is_valid(raise_exception=True):
playlist = serializer.save()
# we update the playlist.library to get the plt.track.uploads locally
if follows := playlist.library.received_follows.filter(approved=True):
playlist.library.schedule_scan(follows[0].actor, force=True)
# we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities
playlist.schedule_scan(actors.get_service_actor())
playlist.schedule_scan(actors.get_service_actor(), force=True)
return
else:
logger.debug(

View File

@ -940,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data):
UNSET = object()
additional_fields = {}
for field in ["name", "summary"]:
for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET)
if v == UNSET:
continue
# in some cases we use the serializer context to pass objects instances, we don't want to add them
if not isinstance(v, str) or isinstance(v, dict):
continue
additional_fields[field] = v
return additional_fields
@ -1037,7 +1040,11 @@ class LibrarySerializer(PaginatedCollectionSerializer):
"page_size": 100,
"attributedTo": library.actor,
"actor": library.actor,
"items": library.uploads.for_federation(),
"items": (
library.uploads.for_federation()
if not library.playlist_uploads.all()
else library.playlist_uploads.for_federation()
),
"type": "Library",
}
r = super().to_representation(conf)
@ -1129,7 +1136,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"last": last,
"items": [
conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False}
i,
context={
"actor": conf["actor"],
"library": conf.get("library", None),
"include_ap_context": False,
},
).data
for i in page.object_list
],
@ -1670,8 +1682,9 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v):
lb = self.context.get("library")
if lb:
if lb.fid != v:
raise serializers.ValidationError("Invalid library")
# the upload can come from a playlist lib
if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb
actor = self.context.get("actor")
@ -1683,10 +1696,10 @@ class UploadSerializer(jsonld.JsonLdSerializer):
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
except Exception:
raise serializers.ValidationError("Invalid library")
except Exception as e:
raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library")
raise serializers.ValidationError("Invalid library, actor check fails")
return library
def update(self, instance, validated_data):
@ -1737,11 +1750,12 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data)
def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track
d = {
"type": "Audio",
"id": instance.get_federation_id(),
"library": instance.library.fid,
"library": lib.fid,
"name": track.full_name,
"published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate,
@ -1760,12 +1774,8 @@ class UploadSerializer(jsonld.JsonLdSerializer):
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": (
contexts.AS.Public
if instance.library.privacy_level == "everyone"
else ""
),
"attributedTo": instance.library.actor.fid,
"to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
"attributedTo": lib.actor.fid,
}
if instance.modification_date:
d["updated"] = instance.modification_date.isoformat()
@ -2325,7 +2335,7 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistTrackSerializer,
serializer_class=PlaylistSerializer,
)
defaults = {
@ -2334,6 +2344,10 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
if existing_plt := playlists_models.PlaylistTrack.objects.filter(
playlist=playlist, index=validated_data["index"]
):
existing_plt.delete()
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
@ -2342,7 +2356,6 @@ class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
"fid": validated_data["id"],
},
)
return plt
@ -2364,6 +2377,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
allow_null=True,
allow_blank=True,
)
library = serializers.URLField(max_length=500, required=True)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
@ -2377,6 +2391,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"library": jsonld.first_id(contexts.FW.library),
},
)
@ -2388,6 +2403,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
"library": playlist.library.fid,
}
payload["audience"] = (
contexts.AS.Public if playlist.privacy_level == "everyone" else ""
@ -2405,12 +2421,22 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
queryset=models.Actor,
serializer_class=ActorSerializer,
)
library = utils.retrieve_ap_object(
validated_data["library"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
"library": library,
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
@ -2420,19 +2446,23 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
),
},
)
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] not in [
if validated_data["audience"] in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
raise serializers.ValidationError("Privacy_level must be everyone")
validated_data["audience"] = "everyone"
else:
validated_data.pop("audience")
return validated_data
def update(self, instance, validated_data):
return self.create(validated_data)
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
@ -2451,6 +2481,8 @@ class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"tracks",
),
"type": "Playlist",
"library": playlist.library.fid,
"published": playlist.creation_date.isoformat(),
}
r = super().to_representation(conf)
return r

View File

@ -23,6 +23,8 @@ music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
music_router.register(r"playlists", views.PlaylistTrackViewSet, "playlist-tracks")
index_router.register(r"index", views.IndexViewSet, "index")

View File

@ -365,6 +365,20 @@ def has_library_access(request, library):
return library.received_follows.filter(actor=actor, approved=True).exists()
def has_playlist_access(request, playlist):
if playlist.privacy_level == "everyone":
return True
if request.user.is_authenticated and request.user.is_superuser:
return True
try:
actor = request.actor
except AttributeError:
return False
return playlist.library.received_follows.filter(actor=actor, approved=True).exists()
class MusicLibraryViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
@ -383,13 +397,16 @@ class MusicLibraryViewSet(
lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(lb.get_absolute_url())
items_qs = (
lb.uploads.for_federation()
if not lb.playlist_uploads.all()
else lb.playlist_uploads.for_federation()
)
conf = {
"id": lb.get_federation_id(),
"actor": lb.actor,
"name": lb.name,
"items": lb.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
"items": items_qs.order_by("-creation_date").prefetch_related(
Prefetch(
"track",
queryset=music_models.Track.objects.select_related(
@ -413,8 +430,8 @@ class MusicLibraryViewSet(
)
),
"item_serializer": serializers.UploadSerializer,
"library": lb,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
@ -709,7 +726,6 @@ 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
@ -728,9 +744,31 @@ class PlaylistViewSet(
"track",
),
"item_serializer": serializers.PlaylistTrackSerializer,
"library": playlist.library.fid,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
page_access_check=lambda: has_playlist_access(request, playlist),
)
class PlaylistTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.PlaylistTrack.objects.local().select_related("actor")
serializer_class = serializers.PlaylistTrackSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
plt = self.get_object()
if not has_playlist_access(request, plt.playlist):
return response.Response(status=403)
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(plt.get_absolute_url())
serializer = self.get_serializer(plt)
return response.Response(serializer.data)

View File

@ -98,6 +98,14 @@ class UploadAdmin(admin.ModelAdmin):
]
list_filter = ["mimetype", "import_status", "library__privacy_level"]
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == "playlist_libraries":
object_id = request.resolver_match.kwargs.get("object_id")
kwargs["queryset"] = models.Library.objects.filter(
playlist_uploads=object_id
).distinct()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(models.UploadVersion)
class UploadVersionAdmin(admin.ModelAdmin):

View File

@ -47,7 +47,7 @@ def create_data(super_user_name=None):
library = federation_factories.MusicLibraryFactory(
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
local=True,
local=True if super_user else False,
)
uploads = music_factories.UploadFactory.create_batch(
size=random.randint(3, 18),
@ -68,6 +68,7 @@ def create_data(super_user_name=None):
playlist = playlist_factories.PlaylistFactory(
name="playlist test public",
privacy_level="everyone",
local=True if super_user else False,
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
)
playlist_factories.PlaylistTrackFactory(playlist=playlist, track=upload.track)
@ -112,7 +113,7 @@ def create_data(super_user_name=None):
# my artist channel
my_artist_library = federation_factories.MusicLibraryFactory(
actor=(super_user.actor if super_user else federation_factories.ActorFactory()),
local=True,
local=True if super_user else False,
)
my_artist_channel = audio_factories.ChannelFactory(
library=my_artist_library,

View File

@ -1,7 +1,7 @@
# Generated by Django 4.2.9 on 2025-01-03 16:12
from django.db import migrations, models
from django.db import IntegrityError
from django.db import migrations, models, transaction
from django.conf import settings
from funkwhale_api.federation import utils as federation_utils
from django.urls import reverse
@ -10,8 +10,9 @@ import uuid
def insert_tracks_to_playlist(apps, playlist, uploads):
PlaylistTrack = apps.get_model("playlists", "PlaylistTrack")
plts = [
PlaylistTrack(
for i, upload in enumerate(uploads):
if upload.track:
PlaylistTrack.objects.create(
creation_date=playlist.creation_date,
playlist=playlist,
track=upload.track,
@ -19,92 +20,189 @@ def insert_tracks_to_playlist(apps, playlist, uploads):
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:playlists-detail",
"federation:music:playlist-tracks-detail",
kwargs={"uuid": new_uuid},
)
),
)
for i, upload in enumerate(uploads)
if upload.track
]
upload.library = None
upload.save()
return PlaylistTrack.objects.bulk_create(plts)
playlist.library.playlist_uploads.set(uploads)
@transaction.atomic
def migrate_libraries_to_playlist(apps, schema_editor):
Playlist = apps.get_model("playlists", "Playlist")
Library = apps.get_model("music", "Library")
LibraryFollow = apps.get_model("federation", "LibraryFollow")
Follow = apps.get_model("federation", "Follow")
User = apps.get_model("users", "User")
Actor = apps.get_model("federation", "Actor")
Channel = apps.get_model("audio", "Channel")
# library to playlist
to_instance_libs = []
to_public_libs = []
to_me_libs = []
for library in Library.objects.all():
playlist = Playlist.objects.create(
if (
not federation_utils.is_local(library.actor.fid)
or library.actor.name == "service"
):
continue
if (
hasattr(library, "playlist")
and library.playlist
and library.uploads.all().exists()
):
uploads = library.uploads.all()
with transaction.atomic():
insert_tracks_to_playlist(apps, library.playlist, uploads)
continue
if (
Channel.objects.filter(library=library).exists()
or Playlist.objects.filter(library=library).exists()
or not federation_utils.is_local(library.fid)
or library.name in ["me", "instance", "everyone"]
):
continue
try:
playlist, created = Playlist.objects.get_or_create(
name=library.name,
library=library,
actor=library.actor,
creation_date=library.creation_date,
privacy_level=library.privacy_level,
description=library.description,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
defaults={
"uuid": (new_uuid := uuid.uuid4()),
"fid": federation_utils.full_url(
reverse(
f"federation:music:playlists-detail",
"federation:music:playlists-detail",
kwargs={"uuid": new_uuid},
)
),
},
)
playlist.save()
if library.uploads.all().exists():
insert_tracks_to_playlist(apps, playlist, library.uploads.all())
uploads = library.uploads.all()
with transaction.atomic():
insert_tracks_to_playlist(apps, playlist, uploads)
# library follows to user follow
for lib_follow in LibraryFollow.objects.filter(target=library):
try:
Follow.objects.create(
uuid=lib_follow.uuid,
target=library.actor,
actor=lib_follow.actor,
approved=lib_follow.approved,
creation_date=lib_follow.creation_date,
modification_date=lib_follow.modification_date,
)
except IntegrityError:
pass
if library.privacy_level == "me":
to_me_libs.append(library)
if library.privacy_level == "instance":
to_instance_libs.append(library)
if library.privacy_level == "everyone":
to_public_libs.append(library)
LibraryFollow.objects.all().delete()
library.privacy_level = "me"
library.playlist = playlist
library.save()
except Exception as e:
print(f"An error occurred during library.playlist creation : {e}")
continue
# migrate uploads to new library
# migrate uploads to new built-in libraries
for actor in Actor.objects.all():
if (
not federation_utils.is_local(actor.fid)
or actor.name == "service"
or hasattr(actor, "channel")
):
continue
privacy_levels = ["me", "instance", "everyone"]
for privacy_level in privacy_levels:
build_in_lib = Library.objects.create(
build_in_lib, created = Library.objects.filter(
channel__isnull=True
).get_or_create(
actor=actor,
privacy_level=privacy_level,
name=privacy_level,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
defaults={
"uuid": (new_uuid := uuid.uuid4()),
"fid": federation_utils.full_url(
reverse(
f"federation:music:libraries-detail",
"federation:music:libraries-detail",
kwargs={"uuid": new_uuid},
)
),
},
)
for library in actor.libraries.filter(privacy_level=privacy_level):
library.uploads.all().update(library=build_in_lib)
if library.pk is not build_in_lib.pk:
library.delete()
library.delete
if privacy_level == "everyone":
for lib in to_public_libs:
lib.uploads.all().update(library=build_in_lib)
if privacy_level == "instance":
for lib in to_instance_libs:
lib.uploads.all().update(library=build_in_lib)
if privacy_level == "me":
for lib in to_me_libs:
lib.uploads.all().update(library=build_in_lib)
def check_succefull_migration(apps, schema_editor):
Actor = apps.get_model("federation", "Actor")
Playlist = apps.get_model("playlists", "Playlist")
for actor in Actor.objects.all():
not_build_in_libs = (
actor.playlists.count()
+ actor.libraries.filter(channel__isnull=False).count()
)
if actor.name == "service" or not federation_utils.is_local(actor.fid):
continue
elif actor.playlists.filter(library__isnull=True).count() > 0:
raise Exception(
f"Incoherent playlist database state : all local playlists do not have lib or too many libs"
)
elif (
not hasattr(actor, "channel")
and actor.libraries.count() - 3 != not_build_in_libs
or (hasattr(actor, "channel") and actor.libraries.count() > 1)
):
raise Exception(
f"Incoherent library database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss.\
actor libs = {actor.libraries.count()} and acto not built-in lib = {not_build_in_libs} \
and acto pl ={actor.playlists.count()} and not channel lib = {actor.libraries.filter(channel__isnull=False).count()} \
and actor.name = {actor.name}"
)
for playlist in Playlist.objects.all():
if not federation_utils.is_local(playlist.fid):
continue
elif playlist.library.privacy_level != "me":
raise Exception(
"Incoherent playlist database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss"
)
class Migration(migrations.Migration):
dependencies = [
("music", "0060_empty_for_test"),
("playlists", "0008_playlist_library_drop"),
("playlists", "0009_playlist_library"),
]
operations = [
migrations.AddField(
model_name="upload",
name="playlist_libraries",
field=models.ManyToManyField(
blank=True,
related_name="playlist_uploads",
to="music.library",
),
),
migrations.RunPython(
migrate_libraries_to_playlist, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
check_succefull_migration, reverse_code=migrations.RunPython.noop
),
]

View File

@ -765,13 +765,14 @@ class UploadQuerySet(common_models.NullsLastQuerySet):
def playable_by(self, actor, include=True):
libraries = Library.objects.viewable_by(actor)
if include:
return self.filter(
library__in=libraries, import_status__in=["finished", "skipped"]
Q(library__in=libraries) | Q(playlist_libraries__in=libraries),
import_status__in=["finished", "skipped"],
)
return self.exclude(
library__in=libraries, import_status__in=["finished", "skipped"]
Q(library__in=libraries) | Q(playlist_libraries__in=libraries),
import_status__in=["finished", "skipped"],
)
def local(self, include=True):
@ -847,6 +848,11 @@ class Upload(models.Model):
related_name="uploads",
on_delete=models.CASCADE,
)
playlist_libraries = models.ManyToManyField(
"library",
blank=True,
related_name="playlist_uploads",
)
# metadata from federation
metadata = JSONField(

View File

@ -555,10 +555,14 @@ class UploadBulkUpdateSerializer(serializers.Serializer):
raise serializers.ValidationError(
f"Upload with uuid {data['uuid']} does not exist"
)
lib = upload.library.actor.libraries.filter(
privacy_level=data["privacy_level"], name=data["privacy_level"]
).exclude(playlist__isnull=False)
upload.library = upload.library.actor.libraries.get(
privacy_level=data["privacy_level"]
)
if len(lib) == 1:
upload.library = lib[0]
else:
raise serializers.ValidationError("Built-in library not found or too many")
return upload

View File

@ -15,3 +15,22 @@ class PlaylistTrackAdmin(admin.ModelAdmin):
list_display = ["playlist", "track", "index"]
search_fields = ["track__name", "playlist__name"]
list_select_related = True
@admin.register(models.PlaylistScan)
class LibraryScanAdmin(admin.ModelAdmin):
list_display = [
"id",
"playlist",
"actor",
"status",
"creation_date",
"modification_date",
"status",
"total_files",
"processed_files",
"errored_files",
]
list_select_related = True
search_fields = ["actor__username", "playlist__name"]
list_filter = ["status"]

View File

@ -3,7 +3,7 @@ from django.conf import settings
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.federation.factories import ActorFactory, MusicLibraryFactory
from funkwhale_api.music.factories import TrackFactory
@ -13,6 +13,7 @@ class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
library = factory.SubFactory(MusicLibraryFactory)
class Meta:
model = "playlists.Playlist"

View File

@ -0,0 +1,86 @@
import django.db.models.deletion
from django.db import migrations, models, transaction
from funkwhale_api.federation import utils as federation_utils
from django.urls import reverse
import uuid
from django.conf import settings
def add_uploads_to_pl_library(playlist, library):
for plt in playlist.playlist_tracks.all():
for upload in plt.track.uploads.filter(library__actor=playlist.actor):
library.uploads.add(upload)
@transaction.atomic
def create_playlist_libraries(apps, schema_editor):
Playlist = apps.get_model("playlists", "Playlist")
Library = apps.get_model("music", "Library")
Actor = apps.get_model("federation", "Actor")
playlist_with_lib_count = 0
for playlist in Playlist.objects.all():
if not federation_utils.is_local(playlist.actor.fid):
continue
library = playlist.library
if not library:
try:
# we don't want to get_or_create in case it's a channel lib
library = Library.objects.create(
name="playlist_" + playlist.name,
privacy_level="me",
actor=playlist.actor,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:libraries-detail",
kwargs={"uuid": new_uuid},
)
),
)
library.save()
playlist.library = library
playlist.save()
with transaction.atomic():
add_uploads_to_pl_library(playlist, library)
except Exception as e:
print(
f"An error occurred during playlist.library creation, raising since we want\
to enforce one lib per playlist"
)
raise e
playlist_with_lib_count = playlist_with_lib_count + 1
local_actors = Actor.objects.filter(domain_id=settings.FEDERATION_HOSTNAME)
if (
Library.objects.filter(
playlist__isnull=False, actor__in=local_actors
).count()
!= playlist_with_lib_count
):
raise Exception(
"Should have the same amount of local playlist and libraries with playlist"
)
class Migration(migrations.Migration):
dependencies = [
("playlists", "0008_playlist_library_drop"),
]
operations = [
migrations.AddField(
model_name="playlist",
name="library",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="playlist",
to="music.library",
),
),
migrations.RunPython(
create_playlist_libraries, reverse_code=migrations.RunPython.noop
),
]

View File

@ -88,12 +88,19 @@ class Playlist(federation_models.FederationMixin):
description = models.TextField(max_length=5000, null=True, blank=True)
objects = PlaylistQuerySet.as_manager()
federation_namespace = "playlists"
library = models.OneToOneField(
"music.Library",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="playlist",
)
def __str__(self):
return self.name
def get_absolute_url(self):
return f"/library/playlists/{self.pk}"
return f"/library/playlists/{self.uuid}"
def get_federation_id(self):
if self.fid:
@ -109,6 +116,19 @@ class Playlist(federation_models.FederationMixin):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
if not self.pk and not self.library_id:
self.library = music_models.Library.objects.create(
actor=self.actor,
name="playlist_" + self.name,
privacy_level="me",
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:libraries-detail", kwargs={"uuid": new_uuid}
),
),
)
return super().save(**kwargs)
@transaction.atomic
@ -232,7 +252,7 @@ class Playlist(federation_models.FederationMixin):
latest_scan = (
self.scans.exclude(status="errored").order_by("-creation_date").first()
)
delay_between_scans = datetime.timedelta(seconds=3600 * 24)
delay_between_scans = datetime.timedelta(seconds=1)
now = timezone.now()
if (
not force
@ -345,6 +365,9 @@ class PlaylistTrack(federation_models.FederationMixin):
return super().save(**kwargs)
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
class PlaylistScan(models.Model):
actor = models.ForeignKey(

View File

@ -15,7 +15,7 @@ class PlaylistXspfRenderer(renderers.BaseRenderer):
if isinstance(data, bytes):
return data
fw_playlist = Playlist.objects.get(id=data["id"])
fw_playlist = Playlist.objects.get(uuid=data["uuid"])
plt_tracks = fw_playlist.playlist_tracks.prefetch_related("track")
top = Element("playlist", version="1", xmlns="http://xspf.org/ns/0/")
title_xspf = SubElement(top, "title")

View File

@ -34,11 +34,14 @@ class PlaylistSerializer(serializers.ModelSerializer):
album_covers = serializers.SerializerMethodField(read_only=True)
is_playable = serializers.SerializerMethodField()
actor = APIActorSerializer(read_only=True)
library = serializers.SerializerMethodField()
library_followed = serializers.SerializerMethodField()
class Meta:
model = models.Playlist
fields = (
"id",
"uuid",
"fid",
"name",
"actor",
"modification_date",
@ -50,8 +53,34 @@ class PlaylistSerializer(serializers.ModelSerializer):
"is_playable",
"actor",
"description",
"library",
"library_followed",
)
read_only_fields = ["id", "modification_date", "creation_date"]
read_only_fields = ["uuid", "fid", "modification_date", "creation_date"]
@extend_schema_field(OpenApiTypes.URI)
def get_library(self, obj):
if obj.library:
return obj.library.fid
else:
return None
@extend_schema_field(OpenApiTypes.BOOL)
def get_library_followed(self, obj):
if self.context.get("request", False) and hasattr(
self.context["request"], "user"
):
actor = self.context["request"].user.actor
lib_qs = obj.library.received_follows.filter(actor=actor)
if lib_qs.exists():
if lib_qs[0].approved is None:
return False
else:
return lib_qs[0].approved
else:
return None
return None
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_playable(self, obj):

View File

@ -1,3 +1,5 @@
import logging
import requests
from django.db.models import F
from django.utils import timezone
@ -9,6 +11,8 @@ from funkwhale_api.taskapp import celery
from . import models
logger = logging.getLogger(__name__)
def get_playlist_data(playlist_url, actor):
auth = signing.get_auth(actor.private_key, actor.private_key_id)
@ -24,7 +28,11 @@ def get_playlist_data(playlist_url, actor):
if scode == 401:
return {"errors": ["This playlist requires authentication"]}
elif scode == 403:
return {"errors": ["Permission denied while scanning playlist"]}
return {
"errors": [
f"Permission denied while scanning playlist. Error : {scode}. PLaylist url = {playlist_url}"
]
}
elif scode >= 400:
return {"errors": [f"Error {scode} while fetching the playlist"]}
serializer = serializers.PlaylistCollectionSerializer(data=response.json())
@ -47,6 +55,7 @@ def get_playlist_page(playlist, page_url, actor):
context={
"playlist": playlist,
"item_serializer": serializers.PlaylistTrackSerializer,
"conf": {"library": playlist.library},
},
)
serializer.is_valid(raise_exception=True)
@ -60,6 +69,7 @@ def get_playlist_page(playlist, page_url, actor):
)
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:
@ -90,13 +100,30 @@ def start_playlist_scan(playlist_scan):
)
def scan_playlist_page(playlist_scan, page_url):
data = get_playlist_page(playlist_scan.playlist, page_url, playlist_scan.actor)
tracks = []
plts = []
for item_serializer in data["items"]:
print(" item_serializer is " + str(item_serializer))
track = item_serializer.save(playlist=playlist_scan.playlist.fid)
tracks.append(track)
try:
plt = item_serializer.save(playlist=playlist_scan.playlist.fid)
# we get any upload owned by the playlist.actor and add a m2m with playlist_libraries
upload_qs = plt.track.uploads.filter(
library__actor=playlist_scan.playlist.actor
)
if not upload_qs:
logger.debug(
f"Could not find a upload for the playlist track {plt.track.title}. Probably the \
playlist.library library_scan failed or was not launched by inbox_update_playlist ?"
)
else:
upload_qs[0].playlist_libraries.add(playlist_scan.playlist.library)
logger.debug(f"Added {plt.track.title} to playlist library")
plts.append(plt)
except Exception as e:
logger.info(
f"Error while saving track to playlist {playlist_scan.playlist}: {e}"
)
continue
playlist_scan.processed_files = F("processed_files") + len(tracks)
playlist_scan.processed_files = F("processed_files") + len(plts)
playlist_scan.modification_date = timezone.now()
update_fields = ["modification_date", "processed_files"]

View File

@ -1,4 +1,5 @@
import logging
from itertools import chain
from django.db import transaction
from django.db.models import Count
@ -29,6 +30,7 @@ class PlaylistViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
serializer_class = serializers.PlaylistSerializer
queryset = (
models.Playlist.objects.all()
@ -157,6 +159,7 @@ class PlaylistViewSet(
)
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {"count": len(plts), "results": serializer.data}
update_playlist_library_uploads(playlist, plts)
playlist.schedule_scan(playlist.actor, force=True)
return Response(data, status=201)
@ -167,7 +170,8 @@ class PlaylistViewSet(
playlist = self.get_object()
playlist.playlist_tracks.all().delete()
playlist.save(update_fields=["modification_date"])
playlist.schedule_scan(playlist.actor)
playlist.library.uploads.filter().delete()
playlist.schedule_scan(playlist.actor, force=True)
return Response(status=204)
def get_queryset(self):
@ -200,6 +204,8 @@ class PlaylistViewSet(
plt = playlist.playlist_tracks.by_index(index)
except models.PlaylistTrack.DoesNotExist:
return Response(status=404)
for upload in plt.track.uploads.filter(playlist_libraries=playlist.library):
upload.playlist_libraries.remove(playlist.library)
plt.delete(update_indexes=True)
plt.playlist.schedule_scan(playlist.actor)
return Response(status=204)
@ -244,7 +250,7 @@ class PlaylistViewSet(
serializer = music_serializers.AlbumSerializer(releases, many=True)
return Response(serializer.data, status=200)
@extend_schema(operation_id="get_playlist_artits")
@extend_schema(operation_id="get_playlist_artists")
@action(methods=["get"], detail=True)
@transaction.atomic
def artists(self, request, *args, **kwargs):
@ -258,3 +264,13 @@ class PlaylistViewSet(
artists = music_models.Artist.objects.filter(pk__in=artists_pks)
serializer = music_serializers.ArtistSerializer(artists, many=True)
return Response(serializer.data, status=200)
def update_playlist_library_uploads(playlist, plts):
uploads = list(
chain(
*[plt.track.uploads.filter(library__actor=playlist.actor) for plt in plts]
)
)
for upload in uploads:
upload.playlist_libraries.add(playlist.library)

View File

@ -471,7 +471,7 @@ def create_user_libraries(user):
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:playlists-detail",
"federation:music:libraries-detail",
kwargs={"uuid": new_uuid},
)
),

View File

@ -22,7 +22,7 @@ def test_mutation_fid_is_populated(factories, model, factory_args, namespace):
("music.Artist", "/library/artists/{obj.pk}"),
("music.Album", "/library/albums/{obj.pk}"),
("music.Track", "/library/tracks/{obj.pk}"),
("playlists.Playlist", "/library/playlists/{obj.pk}"),
("playlists.Playlist", "/library/playlists/{obj.uuid}"),
],
)
def test_get_absolute_url(factory_name, factories, expected):

View File

@ -98,9 +98,10 @@ def test_privacylevel_permission_me(
assert check is expected
# "me" expects true since the object can be private but share with followers
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("followers", True), ("instance", False), ("everyone", True)],
[("me", True), ("followers", True), ("instance", False), ("everyone", True)],
)
def test_privacylevel_permission_followers(
factories, api_request, anonymous_user, privacy_level, expected, mocker

View File

@ -35,6 +35,29 @@ def test_user_can_fetch_library_using_url(mocker, factories, logged_in_api_clien
assert response.data["results"] == [api_serializers.LibrarySerializer(library).data]
def test_user_can_fetch_playlist_library_using_url(
mocker, factories, logged_in_api_client
):
pl_library = factories["music.Library"]()
upload = factories["music.Upload"]()
upload.playlist_libraries.add(pl_library)
mocked_retrieve = mocker.patch(
"funkwhale_api.federation.utils.retrieve_ap_object", return_value=pl_library
)
url = reverse("api:v1:federation:libraries-fetch")
response = logged_in_api_client.post(url, {"fid": pl_library.fid})
assert mocked_retrieve.call_count == 1
args = mocked_retrieve.call_args
assert args[0] == (pl_library.fid,)
assert args[1]["queryset"].model == views.MusicLibraryViewSet.queryset.model
assert args[1]["serializer_class"] == serializers.LibrarySerializer
assert response.status_code == 200
assert response.data["results"] == [
api_serializers.LibrarySerializer(pl_library).data
]
def test_user_can_schedule_library_scan(mocker, factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"](privacy_level="everyone")

View File

@ -1268,6 +1268,7 @@ def test_inbox_update_playlist(factories, mocker):
playlist_data = serializers.PlaylistSerializer(playlist_updated).data
playlist_data["id"] = str(playlist.fid)
playlist_updated.delete()
routes.inbox_update_playlist(
{"object": playlist_data},

View File

@ -1,6 +1,7 @@
from uuid import uuid4
import pytest
from django.conf import settings
from django.utils.timezone import now
# this test is commented since it's very slow, but it can be useful for future development
@ -102,13 +103,13 @@ def test_migrate_libraries_to_playlist(migrator):
Track = music_apps.get_model("music", "Track")
Library = music_apps.get_model("music", "Library")
Upload = music_apps.get_model("music", "Upload")
Playlist = music_apps.get_model("playlists", "Playlist")
# Create data
d = settings.FEDERATION_HOSTNAME
domain = Domain.objects.create()
domain2 = Domain.objects.create(pk=2)
actor = Actor.objects.create(name="Test Actor", domain=domain)
existing_urls = Actor.objects.values_list("fid", flat=True)
print(existing_urls)
actor = Actor.objects.create(name="Test Actor", domain=domain, fid=f"http://{d}/")
target_actor = Actor.objects.create(
name="Test Actor 2",
domain=domain2,
@ -117,12 +118,31 @@ def test_migrate_libraries_to_playlist(migrator):
library = Library.objects.create(
name="This should becane playlist name",
actor=target_actor,
actor=actor,
creation_date=now(),
privacy_level="everyone",
uuid=uuid4(),
description="This is a description",
)
Library.objects.create(
name="me",
actor=actor,
creation_date=now(),
privacy_level="me",
uuid=uuid4(),
description="This is a description",
fid=f"http://{d}/mylocallob",
)
library_not_local = Library.objects.create(
name="This should not becane playlist name",
fid="https://asupernotlocal.acab/federation/music/libraries/8505207e-45da-449a-9ec8-ed12a848fcea",
actor=target_actor,
creation_date=now(),
privacy_level="everyone",
uuid=uuid4(),
description="This is a description recalling to eat the rich",
)
Track.objects.create()
Track.objects.create()
@ -136,6 +156,33 @@ def test_migrate_libraries_to_playlist(migrator):
Upload.objects.create(library=library, track=track3),
]
Upload.objects.create(library=library_not_local, track=track),
Upload.objects.create(library=library_not_local, track=track2),
Upload.objects.create(library=library_not_local, track=track3),
# Plt = music_apps.get_model("playlists", "PlaylistTrack")
# playlist = Playlist.objects.create(
# name="This should becane a library name",
# fid="https://asupernotlocal.acab/federation/music/playlist/8505207e-45da-449a-9ec8-ed12a848fcea",
# actor=actor,
# creation_date=now(),
# privacy_level="everyone",
# uuid=uuid4(),
# )
# playlist_not_local = Playlist.objects.create(
# name="This should not becane a library name",
# actor=target_actor,
# creation_date=now(),
# privacy_level="everyone",
# uuid=uuid4(),
# )
# Plt.objects.create(playlist=playlist, track=track)
# Plt.objects.create(
# playlist=playlist_not_local,
# track=track,
# fid="https://asupernotlocal.acab/federation/music/playlistttrack/8505207e-",
# )
library_follow = LibraryFollow.objects.create(
uuid=uuid4(),
target=library,
@ -152,9 +199,8 @@ def test_migrate_libraries_to_playlist(migrator):
new_apps = migrator.loader.project_state([music_final_migration]).apps
Playlist = new_apps.get_model("playlists", "Playlist")
PlaylistTrack = new_apps.get_model("playlists", "PlaylistTrack")
Follow = new_apps.get_model("federation", "Follow")
LibraryFollow = new_apps.get_model("federation", "LibraryFollow")
Follow = new_apps.get_model("federation", "Follow")
Library = new_apps.get_model("music", "Library")
# Assertions
@ -166,21 +212,43 @@ def test_migrate_libraries_to_playlist(migrator):
assert playlist.privacy_level == library.privacy_level
assert playlist.description == library.description
# Verify Playlist me creation skipped
assert not Playlist.objects.filter(name="me")
assert playlist.actor.libraries.filter(name="me").count() == 1
# Verify PlaylistTrack creation
playlist_tracks = PlaylistTrack.objects.filter(playlist=playlist).order_by("index")
assert playlist_tracks.count() == 3
for i, playlist_track in enumerate(playlist_tracks):
assert playlist_track.track.pk == uploads[i].track.pk
# Verify User Follow creation
follow = Follow.objects.get(target__pk=target_actor.pk)
# Verify playlist.library Follow creation
follow = LibraryFollow.objects.get(target__pk=playlist.library.pk)
assert follow.actor.pk == actor.pk
assert follow.approved == library_follow.approved
assert follow.target == playlist.library
# Verify LibraryFollow deletion and library creation
assert LibraryFollow.objects.count() == 0
# Verify uploads are migrated in lib.playlist_uploads
for upload in uploads:
assert upload.pk in [u.pk for u in playlist.library.playlist_uploads.all()]
assert upload.pk not in [u.pk for u in playlist.library.uploads.all()]
assert not playlist.library.uploads.all()
# Test fail but works on real db I don't get why
# no library are found in the new app
# NewAppLibrary = new_apps.get_model("music", "Library")
# assert NewAppLibrary.objects.count() == 3
# Playlist
# library = Playlist.objects.get(name="This should becane a library name").library
# assert library.name == "This should becane a library name"
# assert library.privacy_level == "me"
# # Not local
# library_not_local = Library.objects.get(fid=library_not_local.fid)
# assert not library_not_local.playlist_uploads.all()
# playlist_not_local = Playlist.objects.get(
# name="This should not becane a library name"
# )
# assert not playlist_not_local.library

View File

@ -1572,7 +1572,7 @@ def test_can_patch_upload_list(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
upload = factories["music.Upload"](library__actor=actor)
upload2 = factories["music.Upload"](library__actor=actor)
factories["music.Library"](actor=actor, privacy_level="everyone")
factories["music.Library"](actor=actor, privacy_level="everyone", name="everyone")
response = logged_in_api_client.patch(
url,
@ -1587,3 +1587,30 @@ def test_can_patch_upload_list(factories, logged_in_api_client):
assert response.status_code == 200
assert upload.library.privacy_level == "everyone"
def test_upload_list_wont_use_playlist_lib(factories, logged_in_api_client):
url = reverse("api:v1:uploads-bulk-update")
actor = logged_in_api_client.user.create_actor()
upload = factories["music.Upload"](library__actor=actor)
upload2 = factories["music.Upload"](library__actor=actor)
playlist = factories["playlists.Playlist"]()
lib = factories["music.Library"](
actor=actor,
privacy_level="everyone",
name="everyone",
)
playlist.library = lib
playlist.save()
response = logged_in_api_client.patch(
url,
[
{"uuid": upload.uuid, "privacy_level": "everyone"},
{"uuid": upload2.uuid, "privacy_level": "everyone"},
],
format="json",
)
upload.refresh_from_db()
upload2.refresh_from_db()
assert response.status_code == 400

View File

@ -286,3 +286,32 @@ def test_playlist_playable_by_anonymous(privacy_level, expected, factories):
queryset = playlist.__class__.objects.playable_by(None).with_playable_plts(None)
match = playlist in list(queryset)
assert match is expected
def test_playlist_playable_by_library_playlist_follower(factories):
plt = factories["playlists.PlaylistTrack"]()
playlist = plt.playlist
playlist.privacy_level = "everyone"
playlist.save()
track = plt.track
upload = factories["music.Upload"](
track=track, library__privacy_level="me", import_status="finished"
)
upload.playlist_libraries.add(playlist.library)
follow = factories["federation.LibraryFollow"](
target=playlist.library, approved=True
)
# skip actortrack denormalization
assert (
plt.track.uploads.all()
.first()
.__class__.objects.playable_by(follow.actor)
.exists()
)
# doesn't skip actortrack denormalization so will fail need the library scan to be triggered
# queryset = playlist.__class__.objects.playable_by(follow.actor).with_playable_plts(
# None
# )
# assert playlist in list(queryset)

View File

@ -75,7 +75,8 @@ def test_playlist_serializer(factories, to_api_date):
actor = playlist.actor
expected = {
"id": playlist.pk,
"uuid": playlist.uuid,
"fid": playlist.fid,
"name": playlist.name,
"privacy_level": playlist.privacy_level,
"is_playable": False,
@ -86,6 +87,8 @@ def test_playlist_serializer(factories, to_api_date):
"tracks_count": 0,
"album_covers": [],
"description": playlist.description,
"library": playlist.library.fid,
"library_followed": None,
}
serializer = serializers.PlaylistSerializer(playlist)

View File

@ -17,6 +17,8 @@ def test_scan_playlist_page_fetches_page_and_creates_tracks(
for i in range(5)
]
for plt in tracks:
factories["music.Upload"](track=plt.track, library__actor=scan.playlist.actor)
page_conf = {
"actor": scan.playlist.actor,
"id": scan.playlist.fid,
@ -35,7 +37,8 @@ def test_scan_playlist_page_fetches_page_and_creates_tracks(
assert len(plts) == 3
for track in tracks[:3]:
scan.playlist.playlist_tracks.get(fid=track.fid)
plt = scan.playlist.playlist_tracks.get(fid=track.fid)
scan.playlist.library in plt.track.uploads.all()[0].playlist_libraries.all()
assert scan.status == "scanning"
assert scan.processed_files == 3

View File

@ -24,7 +24,7 @@ def test_can_get_playlists_octet_stream(factories, logged_in_api_client):
factories["playlists.PlaylistTrack"](playlist=pl)
factories["playlists.PlaylistTrack"](playlist=pl)
url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk})
url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid})
headers = {"Accept": "application/octet-stream"}
response = logged_in_api_client.get(url, headers=headers)
el = etree.fromstring(response.content)
@ -36,7 +36,7 @@ def test_can_get_playlists_octet_stream(factories, logged_in_api_client):
def test_can_get_playlists_json(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"]()
url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk})
url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid})
response = logged_in_api_client.get(url, format="json")
assert response.status_code == 200
assert response.data["name"] == pl.name
@ -105,7 +105,7 @@ def test_can_patch_playlists_octet_stream(factories, logged_in_api_client):
track = factories["music.Track"](
title="Opinel 12", artist_credit__artist=artist, album=album
)
url = reverse("api:v2:playlists-detail", kwargs={"pk": pl.pk})
url = reverse("api:v2:playlists-detail", kwargs={"uuid": pl.uuid})
data = open("./tests/playlists/test.xspf", "rb").read()
response = logged_in_api_client.patch(url, data=data, format="xspf")
pl.refresh_from_db()
@ -118,7 +118,7 @@ def test_can_get_playlists_track(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=pl)
url = reverse("api:v2:playlists-tracks", kwargs={"pk": pl.pk})
url = reverse("api:v2:playlists-tracks", kwargs={"uuid": pl.uuid})
response = logged_in_api_client.get(url)
data = json.loads(response.content.decode("utf-8"))
assert response.status_code == 200
@ -130,7 +130,7 @@ def test_can_get_playlists_releases(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v2:playlists-albums", kwargs={"pk": playlist.pk})
url = reverse("api:v2:playlists-albums", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.get(url)
data = json.loads(response.content)
assert response.status_code == 200
@ -141,7 +141,7 @@ def test_can_get_playlists_artists(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v2:playlists-artists", kwargs={"pk": playlist.pk})
url = reverse("api:v2:playlists-artists", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.get(url)
data = json.loads(response.content)
assert response.status_code == 200

View File

@ -21,7 +21,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](actor=actor)
factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.get(url, content_type="application/json")
assert response.data["tracks_count"] == 1
@ -34,7 +34,7 @@ def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
factories["music.Upload"].create_batch(
3, track=plt.track, library__privacy_level="everyone", import_status="finished"
)
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.get(url, content_type="application/json")
assert response.data["tracks_count"] == 1
@ -45,7 +45,7 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client):
playlist = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.get(url, content_type="application/json")
assert response.data["is_playable"] is False
@ -81,7 +81,7 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli
logged_in_api_client.user.create_actor()
track = factories["music.Track"]()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid})
data = {"tracks": [track.pk]}
response = logged_in_api_client.post(url, data, content_type="application/json")
@ -96,7 +96,7 @@ def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client):
playlist = factories["playlists.Playlist"](actor=actor)
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
url = reverse("api:v1:playlists-remove", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-remove", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.delete(url, {"index": 0})
@ -108,6 +108,40 @@ def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client):
assert plt1.index == 0
def test_deleting_plt_updates_pl_lib(mocker, factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["music.Track"]()
playlist = factories["playlists.Playlist"](actor=actor)
# playlist.library.uploads.add(plt0.track.uploads.all()[0])
# playlist.library.uploads.add(plt1.track.uploads.all()[0])
tracks = factories["music.Track"].create_batch(size=5)
for track in tracks:
factories["music.Upload"](track=track, library__actor=actor)
not_user_actor = factories["federation.Actor"]()
not_user_upload = factories["music.Upload"](
track=tracks[0], library__actor=not_user_actor
)
track_ids = [t.id for t in tracks]
url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid})
logged_in_api_client.post(url, {"tracks": track_ids})
assert not_user_upload not in playlist.library.uploads.all()
for plt in playlist.playlist_tracks.all():
for upload in playlist.library.uploads.all():
assert upload.tracks.filter(id=plt.track.id).exists()
url = reverse("api:v1:playlists-remove", kwargs={"uuid": playlist.uuid})
logged_in_api_client.delete(url, {"index": 0})
playlist.library.refresh_from_db()
for plt in playlist.playlist_tracks.all():
for upload in playlist.library.uploads.all():
assert not upload.tracks.filter(id=plt.track.id).exists()
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_playlist_privacy_respected_in_list_anon(
preferences, level, factories, api_client
@ -124,7 +158,7 @@ def test_playlist_privacy_respected_in_list_anon(
def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-detail", kwargs={"uuid": playlist.uuid})
response = getattr(logged_in_api_client, method.lower())(url)
assert response.status_code == 404
@ -138,7 +172,7 @@ def test_can_add_multiple_tracks_at_once_via_api(
tracks = factories["music.Track"].create_batch(size=5)
track_ids = [t.id for t in tracks]
mocker.spy(playlist, "insert_many")
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.post(url, {"tracks": track_ids})
assert response.status_code == 201
@ -149,6 +183,43 @@ def test_can_add_multiple_tracks_at_once_via_api(
assert plt.track == tracks[plt.index]
def test_add_multiple_tracks_at_once_update_pl_library(
factories, mocker, logged_in_api_client
):
actor = logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](actor=actor)
tracks = factories["music.Track"].create_batch(size=5)
not_user_actor = factories["federation.Actor"]()
not_user_track = factories["music.Track"]()
not_user_upload = factories["music.Upload"](
size=5, track=not_user_track, library__actor=not_user_actor
)
for track in tracks:
factories["music.Upload"](track=track, library__actor=actor)
track_already_in_playlist = factories["music.Track"]()
upload_already_in_playlist = factories["music.Upload"](
track=track_already_in_playlist, library__actor=actor
)
upload_already_in_playlist.playlist_libraries.add(playlist.library)
track_ids = [t.id for t in tracks]
track_ids.append(not_user_track.id)
track_ids.append(track_already_in_playlist.id)
mocker.spy(playlist, "insert_many")
url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.post(url, {"tracks": track_ids})
assert response.status_code == 201
assert playlist.playlist_tracks.count() == len(track_ids)
assert playlist.playlist_tracks.filter(track=not_user_track).exists()
assert not_user_upload not in playlist.library.uploads.all()
for plt in playlist.playlist_tracks.all():
for upload in playlist.library.uploads.all():
assert upload.tracks.filter(id=plt.track.id).exists()
assert len(upload.playlist_libraries.all()) == 1
def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, preferences):
actor = logged_in_api_client.user.create_actor()
preferences["playlists__max_tracks"] = 3
@ -158,7 +229,7 @@ def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, prefer
)
track_ids = [t.id for t in tracks]
mocker.spy(playlist, "insert_many")
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-add", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.post(url, {"tracks": track_ids})
assert response.status_code == 400
@ -168,18 +239,34 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](actor=actor)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-clear", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
assert playlist.playlist_tracks.count() == 0
def test_clear_playlist_from_api_remove_pl_lib_uploads(
factories, mocker, logged_in_api_client
):
actor = logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](actor=actor)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
for upload in playlist.library.uploads.all():
assert upload.playlist_libraries.filter(playlist=playlist).exists()
assert upload.playlist_libraries.get(playlist=playlist).actor == actor
url = reverse("api:v1:playlists-clear", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
assert not playlist.library.uploads.all()
def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](actor=actor)
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={"uuid": playlist.uuid})
response = logged_in_api_client.patch(url, {"name": "test"})
playlist.refresh_from_db()
@ -192,7 +279,7 @@ def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client):
playlist = factories["playlists.Playlist"](actor=actor)
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)
url = reverse("api:v1:playlists-move", kwargs={"pk": playlist.pk})
url = reverse("api:v1:playlists-move", kwargs={"uuid": playlist.uuid})
response = logged_in_api_client.post(url, {"from": 1, "to": 0})

View File

@ -0,0 +1 @@
Use playlists to privately share audio files (#2417)

View File

@ -7,16 +7,18 @@ 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`
Check is done in `activity_pass_user_privacy_level` but only works if `actor` is passed within the `context`. This applies to object that don't have their own privacy_level settings :
- `Listening`
- `TrackFavorite`
### 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.
`Playlist` support it's own privacy level. Check is done in `activity_pass_object_privacy_level` for AP activities and on `has_playlist_access` for the federation views. Other objects should be added manually to this function.
`PlaylistTracks` follow the `Playlist` privacy_level setting.
## Followers privacy_level
## Followers privacy_level for User
If a user follow a local user we don't need to send ActivityPub activities since the data is already in our db. We can use the local database the fetch the data. That's why Funkwhale outbox will always discard activities that are not public. But this need to be updated to support `followers` privacy level. Some warning should be displayed to the users to explain that setting a privacy_level to `followers` will send the data to remote server. This means we need to trust the remote server admins to follow our privacy_level wish. In other words when you trust your followers your also trust the admins of your followers.

View File

@ -0,0 +1,66 @@
## Playlist libraries to share audio files
### The Issue
- As a user I want to share a list of tracks privately to my friends
- As a user I want to have a single container to curate my content (not playlist and libraries, only playlists)
### Proposed Solution
The users can request access to the playlist content to the playlist owner
### Feature Behavior
Users will be able to click on a "Request access to playlist audios files" button. This is a `LibraryFollow` request of the `playlist.library`. Not to be confused with the playlist follow request (see #-followup)
#### Backend
##### Data model
`Playlist` one_to_one with `Library` through `library` field
`Upload` many_to_one with `Library` through `library` (reverse is `library.uploads`)
`Upload` has also a many_to_many with `Library` through `playlist_libraries` (the same upload can be share various time through various playlists). Reverse relation is `library.playlist_uploads`
We could migrate from O2M to M2M, but this is super complicated since : - it adds a lot of extra logic (you can't query the m2m if the instance is not save -> this generated problem to validate incoming AP objects) - having a built-in lib and playlist libs make verifications easier (only three built-in lib, playlist_lib are always private)
##### Workflow
Playlist activity -> library_scan(get the uploads) -> playlist_scan (set the upload.playlist_relation and create plts)
##### Federation
Since `Playlist` is the main object here, we use the `Playlist` activities to send the `Library` information on ActivitiPub.
There is no other reason to share the playlit.library to remote.
##### Migrations
1. Remote library are not changed
2. Local lib are not deleted but are assigned to a playlist
3. Libraries Follows are not touched
4. Remote want fetch local libs as always but they will need to update the data or fail (migrating uploads from `library` to `playlist_library`)
##### Done
- [x] `PlaylistViewSet` `add` `clear` `remove` update the uploads.playlist_libraries relationships
- [x] `PlaylistViewSet` `add` `clear` `remove` -> `schedule_scan` -> Update activity to remote -> playlist.library scan on remote
- [x] library and playlist scan delay are long (24h), force on ap update
- [x] make sure only owned upload are added to the playlist.library
- [x] update the "drop library" migrations to use the playlist.library instead of user follow
- [x] make sure user get the new libraries created after library drop
- [x] update the federation api : when we receive a fetch for a library the upload serializer need to know which lib (playlist lib or user lib)
- [x] Support library.playlist_uploads in library scan -> add playlist_uploads in items in library federation viewset
- [x] investigate library scan bug : don't delete old content of the lib (local cache?): we need to empty the playlist before the scan(not ideal but less work)
- [x] check actor has only have three built-in libs and upload.playlist_libraries is private after migration
- [x] Playlist discovery : fetch federation endpoint for playlists
- [ ] Seem like the federation fetch (either with fetch endpoint or retreive_ap_obj) is deleting the `privacy_level` since `audience` can only be public or null. Avoid `privacy_level` to be updated if it a local playlist.
### Follow up
- [ ] Add the frontend playlist button in the new ui
- [ ] Playlist discovery : display playlist fid in the frontend
- [ ] Document : The user that want to federate need to activate remote activities in it's user settings. Even if the library is public the playlist activities will not be sended to remote -> We need to implement a followers activity setting (#2362)
- [ ] allow users to change the upload to another built-in lib, make sure the upload is not delete (we would loose the playlist_library relation) but only updated
- [ ] Playlist discovery : add the playlist to my playlist collection = follow request to playlist
- [ ] Playlist Track activity (to avoid having to refetch the whole playlist)
- [ ] Add a popover button to force playlist scan ? or make playlist scan delay shorter ?

View File

@ -12,7 +12,8 @@ import Spacer from '~/components/ui/Spacer.vue'
interface Props {
type: string
id: number
id?: number
uuid?: string
}
const { t } = useI18n()
@ -44,7 +45,8 @@ const iframeSrc = computed(() => {
? `&b=${instanceUrl}`
: ''
return `${base}embed.html?&type=${props.type}&id=${props.id}${bParam}`
const identifier = props.id ?? props.uuid
return `${base}embed.html?&type=${props.type}&id=${identifier}${bParam}`
})
const frameWidth = computed(() => width.value ?? '100%')

View File

@ -72,7 +72,8 @@ const {
enqueue,
enqueueNext,
replacePlay,
isLoading
isLoading,
requestPlaylistUploadsAccess
} = usePlayOptions(props)
const { report, getReportableObjects } = useReport()
@ -98,10 +99,50 @@ const labels = computed(() => ({
? t('components.audio.PlayButton.button.playArtist')
: props.playlist
? t('components.audio.PlayButton.button.playPlaylist')
: t('components.audio.PlayButton.button.playTracks')
}))
: t('components.audio.PlayButton.button.playTracks'),
PlaylistUploadGranted: t('components.audio.PlayButton.button.PlaylistUploadGranted'),
PlaylistUploadPending:t('components.audio.PlayButton.button.PlaylistUploadPending'),
PlaylistUploadNotRequest: t('components.audio.PlayButton.button.PlaylistUploadNotRequest'),
PlaylistUploadTooltip: t('components.audio.PlayButton.button.PlaylistUploadTooltip')
}))
const isOpen = ref(false)
const playlistFollowInfo = computed(() => {
const playlist = props.playlist;
if (!playlist) return null;
const followed = playlist.library_followed;
if (followed === true) {
return {
label: labels.value.PlaylistUploadGranted,
tooltip: labels.value.PlaylistUploadTooltip,
icon: 'bi-check-circle',
disabled: true
};
}
if (followed === false) {
return {
label: labels.value.PlaylistUploadPending,
tooltip: labels.value.PlaylistUploadTooltip,
icon: 'bi-hourglass-split',
disabled: true
};
}
// Assume null/undefined means not yet requested
return {
label: labels.value.PlaylistUploadNotRequest,
tooltip: labels.value.PlaylistUploadTooltip,
icon: 'bi-eye-slash',
disabled: false,
action: requestPlaylistUploadsAccess
}
});
</script>
<template>
@ -226,6 +267,15 @@ const isOpen = ref(false)
>
{{ obj.label }}
</PopoverItem>
<PopoverItem
v-if="playlist && playlistFollowInfo"
:title="playlistFollowInfo.tooltip"
:icon="playlistFollowInfo.icon"
:disabled="playlistFollowInfo.disabled"
@click.stop.prevent="requestPlaylistUploadsAccess(playlist)"
>
{{ playlistFollowInfo.label }}
</PopoverItem>
</template>
</Popover>
<Button

View File

@ -30,7 +30,7 @@ let navigate = (to: 'playlist' | 'user') => {}
if (import.meta.env.PROD) {
const router = useRouter()
navigate = (to: 'playlist' | 'user') => to === 'playlist'
? router.push({ name: 'library.playlists.detail', params: { id: playlist.id } })
? router.push({ name: 'library.playlists.detail', params: { id: playlist.uuid } })
: router.push({ name: 'profile.full', params: profileParams.value })
}
</script>

View File

@ -68,7 +68,7 @@ const updatedAgo = computed(() => moment(props.playlist.modification_date).fromN
<template>
<Card
:title="playlist.name"
:to="{ name: 'library.playlists.detail', params: { id: playlist.id } }"
:to="{ name: 'library.playlists.detail', params: { id: playlist.uuid } }"
small
>
<template #topright>

View File

@ -14,7 +14,7 @@ defineProps<Props>()
<!-- TODO: Remove this module -->
<PlaylistsCard
v-for="playlist in playlists"
:key="playlist.id"
:key="playlist.uuid"
:playlist="playlist"
/>
</template>

View File

@ -92,7 +92,7 @@ const responseHandlers = {
const fetchTracks = async () => {
// NOTE: This is handled by other functions and never used directly
const response = await axios.get(`playlists/${playlist.value?.id}/tracks/`)
const response = await axios.get(`playlists/${playlist.value?.uuid}/tracks/`)
playlistTracks.value = response.data.results
}
@ -101,7 +101,7 @@ const reorder = async ({ oldIndex: from, newIndex: to }: { oldIndex: number, new
isLoading.value = true
try {
await axios.post(`playlists/${playlist.value?.id}/move/`, { from, to })
await axios.post(`playlists/${playlist.value?.uuid}/move/`, { from, to })
await store.dispatch('playlists/fetchOwn')
responseHandlers.success()
} catch (error) {
@ -116,7 +116,7 @@ const removePlaylistTrack = async (index: number) => {
try {
tracks.value.splice(index, 1)
await axios.post(`playlists/${playlist.value?.id}/remove/`, { index })
await axios.post(`playlists/${playlist.value?.uuid}/remove/`, { index })
await Promise.all([
store.dispatch('playlists/fetchOwn'),
fetchTracks()
@ -134,7 +134,7 @@ const clearPlaylist = async () => {
try {
tracks.value = []
await axios.delete(`playlists/${playlist.value?.id}/clear/`)
await axios.delete(`playlists/${playlist.value?.uuid}/clear/`)
await store.dispatch('playlists/fetchOwn')
responseHandlers.success()
} catch (error) {
@ -148,7 +148,7 @@ const insertMany = async (insertedTracks: number[], allowDuplicates: boolean) =>
isLoading.value = true
try {
const response = await axios.post(`playlists/${playlist.value?.id}/add/`, {
const response = await axios.post(`playlists/${playlist.value?.uuid}/add/`, {
allow_duplicates: allowDuplicates,
tracks: insertedTracks
})

View File

@ -67,7 +67,7 @@ const submit = async () => {
errors.value = []
try {
const url = props.create ? 'playlists/' : `playlists/${playlist.value?.id}/`
const url = props.create ? 'playlists/' : `playlists/${playlist.value?.uuid}/`
const method = props.create ? 'post' : 'patch'
const data = {

View File

@ -40,7 +40,7 @@ const labels = computed(() => ({
}))
const exportUrl = computed(() => store.getters['instance/absoluteUrl'](`/api/v2/playlists/${props.playlist.id}`))
const exportUrl = computed(() => store.getters['instance/absoluteUrl'](`/api/v2/playlists/${props.playlist.uuid}`))
const exportPlaylist = async () => {
const url = exportUrl.value
const authToken = store.state.auth.oauth.accessToken
@ -67,9 +67,9 @@ const router = useRouter()
const deletePlaylist = async () => {
try {
await axios.delete(`playlists/${props.playlist.id}/`)
await axios.delete(`playlists/${props.playlist.uuid}/`)
store.dispatch('playlists/fetchOwn')
return router.push({ path: '/library' })
await router.push({ path: '/library' }) // <-- await instead of return
} catch (error) {
useErrorHandler(error as Error)
}
@ -161,7 +161,7 @@ const showDeleteModal = ref(false)
<div class="scrolling content">
<div class="description">
<embed-wizard
:id="playlist.id"
:uuid="playlist.uuid"
type="playlist"
/>
</div>

View File

@ -96,7 +96,7 @@ store.dispatch('playlists/fetchOwn')
<h3 class="ui header">
{{ t('components.playlists.PlaylistModal.header.addToPlaylist') }}
<div class="ui sub header">
{{ t('components.playlists.PlaylistModal.header.track', {artist: trackCreditString, title: track.title}) }}
{{ t('components.playlists.PlaylistModal.header.track', { artist: trackCreditString, title: track.title }) }}
</div>
</h3>
</template>
@ -151,7 +151,10 @@ store.dispatch('playlists/fetchOwn')
>
<thead>
<tr>
<th><span class="visually-hidden">{{ t('components.playlists.PlaylistModal.table.edit.header.edit') }}</span></th>
<th>
<span class="visually-hidden">{{ t('components.playlists.PlaylistModal.table.edit.header.edit')
}}</span>
</th>
<th>
{{ t('components.playlists.PlaylistModal.table.edit.header.name') }}
</th>
@ -173,7 +176,7 @@ store.dispatch('playlists/fetchOwn')
solid
secondary
square-small
:to="{name: 'library.playlists.detail', params: {id: playlist.id }, query: {mode: 'edit'}}"
:to="{ name: 'library.playlists.detail', params: { id: playlist.uuid }, query: { mode: 'edit' } }"
icon="bi-pencil-fill"
>
<span class="visually-hidden">{{ t('components.playlists.PlaylistModal.button.edit') }}</span>
@ -181,7 +184,7 @@ store.dispatch('playlists/fetchOwn')
</td>
<td>
<router-link
:to="{name: 'library.playlists.detail', params: {id: playlist.id }}"
:to="{ name: 'library.playlists.detail', params: { id: playlist.uuid } }"
@click="store.state.playlists.showModal = false"
>
{{ playlist.name }}
@ -196,7 +199,7 @@ store.dispatch('playlists/fetchOwn')
primary
:title="labels.addToPlaylist"
icon="bi-plus"
@click.prevent="addToPlaylist(playlist.id, false)"
@click.prevent="addToPlaylist(playlist.uuid, false)"
>
{{ t('components.playlists.PlaylistModal.button.addTrack') }}
</Button>

View File

@ -100,7 +100,7 @@ watch(
</Alert>
<PlaylistCard
v-for="playlist in objects"
:key="playlist.id"
:key="playlist.uuid"
:playlist="playlist"
/>
</Section>

View File

@ -129,7 +129,7 @@ export default (props: PlayOptionsProps) => {
tracks.push(response.data as Track)
}
} else if (props.playlist) {
const response = await axios.get(`playlists/${props.playlist.id}/tracks/`)
const response = await axios.get(`playlists/${props.playlist.uuid}/tracks/`)
const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track)
const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id)
@ -200,6 +200,29 @@ export default (props: PlayOptionsProps) => {
return replacePlay(index)
}
const requestPlaylistUploadsAccess = async (playlist: Playlist) => {
const libraryUrl = playlist.library;
if (!libraryUrl) {
throw new Error("Playlist library URL is missing.");
}
const libResponse = await axios.get(libraryUrl);
const id = libResponse.data?.id || libResponse.data?.results?.id;
if (!id) {
throw new Error("Library id not found in response.");
}
const fetchResponse = await axios.post('federation/fetches',
{ object: id }
);
const response = await axios.post(
'federation/follows/library',
{ target: fetchResponse.data.object.uuid }
);
return response;
};
return {
playable,
filterableArtist,
@ -208,6 +231,7 @@ export default (props: PlayOptionsProps) => {
enqueueNext,
replacePlay,
activateTrack,
isLoading
isLoading,
requestPlaylistUploadsAccess
}
}

View File

@ -112,7 +112,7 @@ const getReportableObjects = ({ track, album, artist, artistCredit, playlist, ac
label: t('composables.moderation.useReport.playlist.label'),
target: {
type: 'playlist',
id: playlist.id,
uuid: playlist.uuid,
label: playlist.name,
_obj: playlist,
typeLabel: t('composables.moderation.useReport.playlist.typeLabel')

View File

@ -2233,7 +2233,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/": {
"/api/v1/playlists/{uuid}/": {
parameters: {
query?: never;
header?: never;
@ -2249,7 +2249,7 @@ export interface paths {
patch: operations["partial_update_playlist"];
trace?: never;
};
"/api/v1/playlists/{id}/add/": {
"/api/v1/playlists/{uuid}/add/": {
parameters: {
query?: never;
header?: never;
@ -2265,7 +2265,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/albums/": {
"/api/v1/playlists/{uuid}/albums/": {
parameters: {
query?: never;
header?: never;
@ -2281,14 +2281,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/artists/": {
"/api/v1/playlists/{uuid}/artists/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_playlist_artits"];
get: operations["get_playlist_artists"];
put?: never;
post?: never;
delete?: never;
@ -2297,7 +2297,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/clear/": {
"/api/v1/playlists/{uuid}/clear/": {
parameters: {
query?: never;
header?: never;
@ -2313,7 +2313,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/move/": {
"/api/v1/playlists/{uuid}/move/": {
parameters: {
query?: never;
header?: never;
@ -2329,7 +2329,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/remove/": {
"/api/v1/playlists/{uuid}/remove/": {
parameters: {
query?: never;
header?: never;
@ -2345,7 +2345,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/playlists/{id}/tracks/": {
"/api/v1/playlists/{uuid}/tracks/": {
parameters: {
query?: never;
header?: never;
@ -5226,7 +5226,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/": {
"/api/v2/playlists/{uuid}/": {
parameters: {
query?: never;
header?: never;
@ -5242,7 +5242,7 @@ export interface paths {
patch: operations["partial_update_playlist_2"];
trace?: never;
};
"/api/v2/playlists/{id}/add/": {
"/api/v2/playlists/{uuid}/add/": {
parameters: {
query?: never;
header?: never;
@ -5258,7 +5258,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/albums/": {
"/api/v2/playlists/{uuid}/albums/": {
parameters: {
query?: never;
header?: never;
@ -5274,14 +5274,14 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/artists/": {
"/api/v2/playlists/{uuid}/artists/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_playlist_artits_2"];
get: operations["get_playlist_artists_2"];
put?: never;
post?: never;
delete?: never;
@ -5290,7 +5290,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/clear/": {
"/api/v2/playlists/{uuid}/clear/": {
parameters: {
query?: never;
header?: never;
@ -5306,7 +5306,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/move/": {
"/api/v2/playlists/{uuid}/move/": {
parameters: {
query?: never;
header?: never;
@ -5322,7 +5322,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/remove/": {
"/api/v2/playlists/{uuid}/remove/": {
parameters: {
query?: never;
header?: never;
@ -5338,7 +5338,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v2/playlists/{id}/tracks/": {
"/api/v2/playlists/{uuid}/tracks/": {
parameters: {
query?: never;
header?: never;
@ -8497,7 +8497,10 @@ export interface components {
summary?: components["schemas"]["ContentRequest"] | null;
};
Playlist: {
readonly id: number;
/** Format: uuid */
readonly uuid: string;
/** Format: uri */
readonly fid: string;
name: string;
readonly actor: components["schemas"]["APIActor"];
/** Format: date-time */
@ -8510,6 +8513,9 @@ export interface components {
readonly duration: number;
readonly is_playable: boolean;
description?: string | null;
/** Format: uri */
readonly library: string;
readonly library_followed: boolean;
};
PlaylistAddManyRequest: {
tracks: number[];
@ -14142,8 +14148,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14167,8 +14172,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14199,8 +14203,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14222,8 +14225,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14254,8 +14256,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14286,8 +14287,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14304,15 +14304,14 @@ export interface operations {
};
};
};
get_playlist_artits: {
get_playlist_artists: {
parameters: {
query?: {
format?: "json" | "xspf";
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14336,8 +14335,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14359,8 +14357,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14391,8 +14388,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14423,8 +14419,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -14460,8 +14455,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21155,8 +21149,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21180,8 +21173,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21212,8 +21204,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21235,8 +21226,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21267,8 +21257,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21299,8 +21288,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21317,15 +21305,14 @@ export interface operations {
};
};
};
get_playlist_artits_2: {
get_playlist_artists_2: {
parameters: {
query?: {
format?: "json" | "xspf";
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21349,8 +21336,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21372,8 +21358,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21404,8 +21389,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21436,8 +21420,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};
@ -21473,8 +21456,7 @@ export interface operations {
};
header?: never;
path: {
/** @description A unique integer value identifying this playlist. */
id: number;
uuid: string;
};
cookie?: never;
};

View File

@ -483,6 +483,10 @@
"playArtist": "Play artist",
"playNext": "Play next",
"playNow": "Play now",
"PlaylistUploadGranted": "The owner of the playlist granted you access to his playlist's files",
"PlaylistUploadTooltip": "This only applies to files owned by the playlist actor. If you want to get access to all the files, think to buy them to support your favorites artists",
"PlaylistUploadPending": "You've already requested access to the playlist files",
"PlaylistUploadNotRequest": "Request access to the files from this playlist",
"playPlaylist": "Play playlist",
"playTrack": "Play track",
"playTracks": "Play tracks",
@ -3216,7 +3220,7 @@
},
"useErrorHandler": {
"errorReportMessage": "To help us understand why it happened, please attach a detailed description of what you did that has triggered the error.",
"errorReportTitle": "An unexpected error occured.",
"errorReportTitle": "An unexpected error occurred.",
"leaveFeedback": "Leave feedback",
"unexpectedError": "An unexpected error occurred."
},
@ -4279,7 +4283,7 @@
"showStatus": "Show information about the upload status for this track"
},
"empty": {
"noTracks": "No tracks have been added to this libray yet"
"noTracks": "No tracks have been added to this library yet"
},
"label": {
"importStatus": "Import status",

View File

@ -414,7 +414,7 @@ const radioConfig = computed<RadioConfig | null>(() =>
: count({ type: 'playlists' }) > 0
? ({
type: 'playlist',
ids: resultsPerCategory({ type: 'playlists' }).map(({ id }) => id.toString())
ids: resultsPerCategory({ type: 'playlists' }).map(({ uuid }) => uuid.toString())
})
: count({ type: 'artists' }) > 0
? ({

View File

@ -44,16 +44,12 @@ const store = useStore()
const edit = ref(props.defaultEdit)
const playlist = ref<Playlist | null>(null)
const playlistTracks = ref<PlaylistTrack[]>([])
const showEmbedModal = ref(false)
// TODO: Compute `tracks` with `track`. In the new types, `track` is just a string.
// We probably have to load each track before loading it into `tracks`.
// Original line: const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ({ ...track, position: index + 1 })))
const tracks = computed(() => playlistTracks.value.map(({ track }, index) => (
// @i-would-expect-ts-to-error because this typecasting is evil
{ position: index + 1 } as Track
)))
type FullPlaylistTrack = Omit<PlaylistTrack, 'track'> & { track: Track }
const fullPlaylistTracks = ref<FullPlaylistTrack[]>([])
const tracks = computed(() => fullPlaylistTracks.value.map(({ track }, index) => ({ ...track as Track, position: index + 1 })))
const { t } = useI18n()
const labels = computed(() => ({
@ -71,7 +67,7 @@ const fetchData = async () => {
])
playlist.value = playlistResponse.data
playlistTracks.value = tracksResponse.data.results
fullPlaylistTracks.value = tracksResponse.data.results
} catch (error) {
useErrorHandler(error as Error)
}
@ -277,7 +273,7 @@ const shuffle = () => {}
<div class="scrolling content">
<div class="description">
<embed-wizard
:id="playlist.id"
:uuid="playlist.uuid"
type="playlist"
/>
</div>

View File

@ -254,7 +254,7 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
/>
<PlaylistsCard
v-for="playlist in (result && result.results.length > 0 ? result.results : [])"
:key="playlist.id"
:key="playlist.uuid"
:playlist="playlist"
/>
<Spacer grow />