Use playlists to privately share audio files (#2417)
This commit is contained in:
parent
36030c68ee
commit
4549dcb61e
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
),
|
||||
]
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
),
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Use playlists to privately share audio files (#2417)
|
|
@ -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.
|
||||
|
|
|
@ -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 ?
|
|
@ -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%')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -100,7 +100,7 @@ watch(
|
|||
</Alert>
|
||||
<PlaylistCard
|
||||
v-for="playlist in objects"
|
||||
:key="playlist.id"
|
||||
:key="playlist.uuid"
|
||||
:playlist="playlist"
|
||||
/>
|
||||
</Section>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
? ({
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
Loading…
Reference in New Issue