kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Use playlists to privately share audio files (#2417)
rodzic
a9927df89c
commit
2d6374747c
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
),
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
),
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Use playlists to privately share audio files (#2417)
|
|
@ -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
|
Ładowanie…
Reference in New Issue