Use playlists to privately share audio files (#2417)

environments/review-docs-2417-tnci47/deployments/20786
Petitminion 2025-03-11 00:37:58 +01:00
rodzic a9927df89c
commit 2d6374747c
25 zmienionych plików z 400 dodań i 33 usunięć

Wyświetl plik

@ -106,9 +106,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.user.actor.get_approved_followers():
return True
else:
return False

Wyświetl plik

@ -807,18 +807,18 @@ 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 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)
# we update the playlist.library
if follows := playlist.library.received_follows.filter(approved=True):
playlist.library.schedule_scan(follows[0].actor, force=True)
return
else:
logger.debug(

Wyświetl plik

@ -1037,7 +1037,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)
@ -1670,8 +1674,8 @@ 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")
if lb.fid != v and not lb.playlist:
raise serializers.ValidationError("Invalid library fid")
return lb
actor = self.context.get("actor")
@ -1683,10 +1687,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):
@ -2325,7 +2329,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 +2338,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,
@ -2364,6 +2372,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 +2386,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 +2398,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,11 +2416,20 @@ 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,6 +2440,7 @@ class PlaylistSerializer(jsonld.JsonLdSerializer):
),
},
)
return playlist
def validate(self, data):

Wyświetl plik

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

Wyświetl plik

@ -383,13 +383,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(
@ -734,3 +737,22 @@ class PlaylistViewSet(
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
)
class PlaylistTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
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 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)

Wyświetl plik

@ -19,7 +19,7 @@ def insert_tracks_to_playlist(apps, playlist, uploads):
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:playlists-detail",
f"federation:music:playlist-tracks-detail",
kwargs={"uuid": new_uuid},
)
),

Wyświetl plik

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-09 21:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0063_upload_third_party_provider"),
]
operations = [
migrations.AddField(
model_name="upload",
name="playlist_libraries",
field=models.ManyToManyField(
blank=True,
null=True,
related_name="playlist_uploads",
to="music.library",
),
),
]

Wyświetl plik

@ -744,13 +744,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):
@ -826,6 +827,12 @@ class Upload(models.Model):
related_name="uploads",
on_delete=models.CASCADE,
)
playlist_libraries = models.ManyToManyField(
"library",
null=True,
blank=True,
related_name="playlist_uploads",
)
# metadata from federation
metadata = JSONField(

Wyświetl plik

@ -549,9 +549,8 @@ class UploadBulkUpdateSerializer(serializers.Serializer):
raise serializers.ValidationError(
f"Upload with uuid {data['uuid']} does not exist"
)
upload.library = upload.library.actor.libraries.get(
privacy_level=data["privacy_level"]
privacy_level=data["privacy_level"], name=data["privacy_level"]
)
return upload

Wyświetl plik

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

Wyświetl plik

@ -13,6 +13,9 @@ class PlaylistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
library = factory.SubFactory(
"funkwhale_api.federation.factories.MusicLibraryFactory"
)
class Meta:
model = "playlists.Playlist"

Wyświetl plik

@ -0,0 +1,58 @@
import django.db.models.deletion
from django.db import migrations, models
# to do : test migration
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)
def create_playlist_libraries(apps, schema_editor):
Playlist = apps.get_model("playlists", "Playlist")
Library = apps.get_model("music", "Library")
for playlist in Playlist.objects.all():
library = playlist.library
if library is None:
library = Library.objects.create(
name=playlist.name, privacy_level="me", actor=playlist.actor
)
library.save()
playlist.library = library
playlist.save()
add_uploads_to_pl_library(playlist, library)
class Migration(migrations.Migration):
dependencies = [
("music", "0063_upload_third_party_provider"),
("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
),
migrations.AlterField(
model_name="playlist",
name="library",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="playlist",
to="music.library",
),
),
]

Wyświetl plik

@ -88,6 +88,11 @@ 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",
on_delete=models.CASCADE,
related_name="playlist",
)
def __str__(self):
return self.name
@ -109,6 +114,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
@ -345,6 +363,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(

Wyświetl plik

@ -34,6 +34,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
album_covers = serializers.SerializerMethodField(read_only=True)
is_playable = serializers.SerializerMethodField()
actor = APIActorSerializer(read_only=True)
library = serializers.SerializerMethodField()
class Meta:
model = models.Playlist
@ -48,10 +49,14 @@ class PlaylistSerializer(serializers.ModelSerializer):
"album_covers",
"duration",
"is_playable",
"actor",
"library",
)
read_only_fields = ["id", "modification_date", "creation_date"]
@extend_schema_field(OpenApiTypes.UUID)
def get_library(self, obj):
return obj.library.fid
@extend_schema_field(OpenApiTypes.BOOL)
def get_is_playable(self, obj):
return getattr(obj, "is_playable_by_actor", False)

Wyświetl plik

@ -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())
@ -59,7 +67,6 @@ def get_playlist_page(playlist, page_url, actor):
"playlist_scan",
)
def start_playlist_scan(playlist_scan):
playlist_scan.playlist.playlist_tracks.all().delete()
try:
data = get_playlist_data(playlist_scan.playlist.fid, actor=playlist_scan.actor)
except Exception:
@ -92,9 +99,14 @@ def scan_playlist_page(playlist_scan, page_url):
data = get_playlist_page(playlist_scan.playlist, page_url, playlist_scan.actor)
tracks = []
for item_serializer in data["items"]:
print(" item_serializer is " + str(item_serializer))
track = item_serializer.save(playlist=playlist_scan.playlist.fid)
tracks.append(track)
try:
track = item_serializer.save(playlist=playlist_scan.playlist.fid)
tracks.append(track)
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.modification_date = timezone.now()

Wyświetl plik

@ -1,4 +1,5 @@
import logging
from itertools import chain
from django.db import transaction
from django.db.models import Count
@ -157,6 +158,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,6 +169,7 @@ class PlaylistViewSet(
playlist = self.get_object()
playlist.playlist_tracks.all().delete()
playlist.save(update_fields=["modification_date"])
playlist.library.uploads.filter().delete()
playlist.schedule_scan(playlist.actor)
return Response(status=204)
@ -200,6 +203,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)
@ -258,3 +263,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)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -85,6 +85,7 @@ def test_playlist_serializer(factories, to_api_date):
"duration": 0,
"tracks_count": 0,
"album_covers": [],
"library": playlist.library.fid,
}
serializer = serializers.PlaylistSerializer(playlist)

Wyświetl plik

@ -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={"pk": playlist.pk})
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={"pk": playlist.pk})
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
@ -149,6 +183,35 @@ 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_ids = [t.id for t in tracks]
track_ids.append(not_user_track.id)
mocker.spy(playlist, "insert_many")
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
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():
upload.tracks.filter(id=plt.track.id).exists()
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
@ -175,6 +238,22 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
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={"pk": playlist.pk})
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)

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,28 @@
## Playlist Import export
### The Issue
- Has a user I want to share a list of tracks privately to mmy friends
- Has 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
- [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
- [ ] make sure only owned upload are added to the playlist.library
- [ ] update the "drop library" migrations to use the playlist.library instead of user follow
- [ ] make sure user get the new libraries created after library drop
### Follow up
- [ ] Playlist discovery : fetch federation endpoint for playlists
- [ ] Playlist discovery : add the playlist to my playlist collection = follow request to playlist