Drop libraries in favor of playlist for user audio sharing (#2366)

This commit is contained in:
petitminion 2025-01-04 15:03:49 +00:00
parent 7102da8ed3
commit c9d915fb33
39 changed files with 424 additions and 209 deletions

View File

@ -49,6 +49,7 @@ def handler_create_user(
utils.logger.warn("Unknown permission %s", permission)
utils.logger.debug("Creating actor…")
user.actor = models.create_actor(user)
models.create_user_libraries(user)
user.save()
return user

View File

@ -627,12 +627,6 @@ def get_actors_from_audience(urls):
final_query, Q(pk__in=actor_follows.values_list("actor", flat=True))
)
library_follows = models.LibraryFollow.objects.filter(
queries["followed"], approved=True
)
final_query = funkwhale_utils.join_queries_or(
final_query, Q(pk__in=library_follows.values_list("actor", flat=True))
)
if not final_query:
return models.Actor.objects.none()
return models.Actor.objects.filter(final_query)

View File

@ -56,7 +56,6 @@ class LibrarySerializer(serializers.ModelSerializer):
"uuid",
"actor",
"name",
"description",
"creation_date",
"uploads_count",
"privacy_level",

View File

@ -173,13 +173,9 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
uuid = factory.Faker("uuid4")
actor = factory.SubFactory(ActorFactory)
privacy_level = "me"
name = factory.Faker("sentence")
description = factory.Faker("sentence")
name = privacy_level
uploads_count = 0
fid = factory.Faker("federation_url")
followers_url = factory.LazyAttribute(
lambda o: o.fid + "/followers" if o.fid else None
)
class Meta:
model = "music.Library"

View File

@ -9,7 +9,7 @@ MODELS = [
(music_models.Album, ["fid"]),
(music_models.Track, ["fid"]),
(music_models.Upload, ["fid"]),
(music_models.Library, ["fid", "followers_url"]),
(music_models.Library, ["fid"]),
(
federation_models.Actor,
[

View File

@ -166,7 +166,7 @@ def outbox_follow(context):
def outbox_create_audio(context):
upload = context["upload"]
channel = upload.library.get_channel()
followers_target = channel.actor if channel else upload.library
followers_target = channel.actor if channel else upload.library.actor
actor = channel.actor if channel else upload.library.actor
if channel:
serializer = serializers.ChannelCreateUploadSerializer(upload)
@ -310,8 +310,8 @@ def outbox_delete_audio(context):
uploads = context["uploads"]
library = uploads[0].library
channel = library.get_channel()
followers_target = channel.actor if channel else library
actor = channel.actor if channel else library.actor
followers_target = channel.actor if channel else actor
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
@ -679,6 +679,9 @@ 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"]

View File

@ -995,8 +995,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
@ -1013,9 +1011,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
},
@ -1037,7 +1033,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
conf = {
"id": library.fid,
"name": library.name,
"summary": library.description,
"page_size": 100,
"attributedTo": library.actor,
"actor": library.actor,
@ -1048,7 +1043,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else ""
)
r["followers"] = library.followers_url
return r
def create(self, validated_data):
@ -1068,8 +1062,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={
"uploads_count": validated_data["totalItems"],
"name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]],
},
)

View File

@ -387,7 +387,6 @@ class MusicLibraryViewSet(
"id": lb.get_federation_id(),
"actor": lb.actor,
"name": lb.name,
"summary": lb.description,
"items": lb.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(

View File

@ -572,7 +572,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name")
actor = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
class Meta:
model = music_models.Library
@ -582,14 +581,11 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"fid",
"url",
"name",
"description",
"domain",
"is_local",
"creation_date",
"privacy_level",
"uploads_count",
"followers_count",
"followers_url",
"actor",
]
read_only_fields = [
@ -605,10 +601,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
def get_uploads_count(self, obj) -> int:
return getattr(obj, "_uploads_count", int(obj.uploads_count))
@extend_schema_field(OpenApiTypes.INT)
def get_followers_count(self, obj):
return getattr(obj, "followers_count", None)
class ManageNestedLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name")
@ -622,12 +614,10 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer):
"fid",
"url",
"name",
"description",
"domain",
"is_local",
"creation_date",
"privacy_level",
"followers_url",
"actor",
]

View File

@ -164,7 +164,6 @@ class LibraryStateSerializer(serializers.ModelSerializer):
"uuid",
"fid",
"name",
"description",
"creation_date",
"privacy_level",
]

View File

@ -10,7 +10,7 @@ from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.history import factories as history_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.playlists import factories as playlist_factories
from funkwhale_api.users import serializers
from funkwhale_api.users import models, serializers
logger = logging.getLogger(__name__)
@ -37,6 +37,7 @@ def create_data(count=2, super_user_name=None):
print(
f"Superuser {super_user_name} already in db. Skipping fake-data creation"
)
super_user = models.User.objects.get(username=super_user_name)
continue
else:
raise e
@ -68,6 +69,9 @@ def create_data(count=2, super_user_name=None):
),
)
playlist_factories.PlaylistTrackFactory(playlist=playlist, track=upload.track)
federation_factories.LibraryFollowFactory.create_batch(
size=random.randint(3, 18), actor=super_user.actor
)
if __name__ == "__main__":

View File

@ -36,6 +36,7 @@ def set_all_artists_credit(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("music", "0058_upload_quality"),
("playlists", "0008_playlist_library_drop"),
]
operations = [

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.9 on 2025-01-03 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0059_remove_album_artist_remove_track_artist_artistcredit_and_more"),
("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"),
]
operations = [
migrations.RemoveField(
model_name="library",
name="description",
),
migrations.RemoveField(
model_name="library",
name="followers_url",
),
]

View File

@ -0,0 +1,108 @@
# Generated by Django 4.2.9 on 2025-01-03 16:12
from django.db import migrations, models
from django.db import IntegrityError
from funkwhale_api.federation import utils as federation_utils
from django.urls import reverse
import uuid
def insert_tracks_to_playlist(apps, playlist, uploads):
PlaylistTrack = apps.get_model("playlists", "PlaylistTrack")
plts = [
PlaylistTrack(
creation_date=playlist.creation_date,
playlist=playlist,
track=upload.track,
index=0 + i,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:playlists-detail",
kwargs={"uuid": new_uuid},
)
),
)
for i, upload in enumerate(uploads)
if upload.track
]
return PlaylistTrack.objects.bulk_create(plts)
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")
# library to playlist
for library in Library.objects.all():
playlist = Playlist.objects.create(
name=library.name,
actor=library.actor,
creation_date=library.creation_date,
privacy_level=library.privacy_level,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:playlists-detail",
kwargs={"uuid": new_uuid},
)
),
)
playlist.save()
if library.uploads.all().exists():
insert_tracks_to_playlist(apps, playlist, library.uploads.all())
# 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
LibraryFollow.objects.all().delete()
# migrate uploads to new library
for actor in Actor.objects.all():
privacy_levels = ["me", "instance", "everyone"]
for privacy_level in privacy_levels:
build_in_lib = Library.objects.create(
actor=actor,
privacy_level=privacy_level,
name=privacy_level,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
f"federation:music:playlists-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()
class Migration(migrations.Migration):
dependencies = [
("music", "0060_remove_library_description_and_more"),
]
operations = [
migrations.RunPython(
migrate_libraries_to_playlist, reverse_code=migrations.RunPython.noop
),
]

View File

@ -1319,10 +1319,8 @@ class Library(federation_models.FederationMixin):
actor = models.ForeignKey(
"federation.Actor", related_name="libraries", on_delete=models.CASCADE
)
followers_url = models.URLField(max_length=500)
creation_date = models.DateTimeField(default=timezone.now)
name = models.CharField(max_length=100)
description = models.TextField(max_length=5000, null=True, blank=True)
privacy_level = models.CharField(
choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25
)

View File

@ -329,7 +329,6 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer):
"uuid",
"fid",
"name",
"description",
"privacy_level",
"uploads_count",
"size",
@ -528,6 +527,26 @@ class UploadForOwnerSerializer(UploadSerializer):
return f
class UploadBulkUpdateSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
privacy_level = serializers.ChoiceField(
choices=models.LIBRARY_PRIVACY_LEVEL_CHOICES
)
def validate(self, data):
try:
upload = models.Upload.objects.get(uuid=data["uuid"])
except models.Upload.DoesNotExist:
raise serializers.ValidationError(
f"Upload with uuid {data['uuid']} does not exist"
)
upload.library = upload.library.actor.libraries.get(
privacy_level=data["privacy_level"]
)
return upload
class UploadActionSerializer(common_serializers.ActionSerializer):
actions = [
common_serializers.Action("delete", allow_all=True),

View File

@ -340,7 +340,6 @@ def library_library(request, uuid, redirect_to_ap):
{"tag": "meta", "property": "og:url", "content": library_url},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": obj.name},
{"tag": "meta", "property": "og:description", "content": obj.description},
]
if preferences.get("federation__enabled"):

View File

@ -293,11 +293,8 @@ class AlbumViewSet(
class LibraryViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
@ -332,42 +329,6 @@ class LibraryViewSet(
return qs
def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor)
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": "Library"}},
context={"library": instance},
)
instance.delete()
@extend_schema(
responses=federation_api_serializers.LibraryFollowSerializer(many=True)
)
@action(
methods=["get"],
detail=True,
)
@transaction.non_atomic_requests
def follows(self, request, *args, **kwargs):
library = self.get_object()
queryset = (
library.received_follows.filter(target__actor=self.request.user.actor)
.prefetch_related("actor", "target__actor")
.order_by("-creation_date")
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = federation_api_serializers.LibraryFollowSerializer(
page, many=True, required=False
)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True, required=False)
return Response(serializer.data)
# TODO quickfix, basically specifying the response would be None
@extend_schema(responses=None)
@action(
@ -830,7 +791,7 @@ class UploadViewSet(
return Response(payload, status=200)
@action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
def perform_upload_action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.UploadActionSerializer(request.data, queryset=queryset)
serializer.is_valid(raise_exception=True)
@ -860,6 +821,23 @@ class UploadViewSet(
)
instance.delete()
@action(detail=False, methods=["patch"])
def bulk_update(self, request, *args, **kwargs):
"""
Used to move an upload from one library to another. Receive a upload uuid and a privacy_level
"""
serializer = serializers.UploadBulkUpdateSerializer(
data=request.data, many=True
)
serializer.is_valid(raise_exception=True)
models.Upload.objects.bulk_update(serializer.validated_data, ["library"])
return Response(
serializers.UploadForOwnerSerializer(serializer.validated_data).data,
status=200,
)
class Search(views.APIView):
max_results = 3

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.9 on 2025-01-03 16:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"),
]
operations = [
migrations.AlterField(
model_name="playlist",
name="name",
field=models.CharField(max_length=100),
),
migrations.AddField(
model_name="playlist",
name="description",
field=models.TextField(blank=True, max_length=5000, null=True),
),
]

View File

@ -78,14 +78,14 @@ class PlaylistQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
class Playlist(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
name = models.CharField(max_length=50)
name = models.CharField(max_length=100)
actor = models.ForeignKey(
"federation.Actor", related_name="playlists", on_delete=models.CASCADE
)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
privacy_level = fields.get_privacy_field()
description = models.TextField(max_length=5000, null=True, blank=True)
objects = PlaylistQuerySet.as_manager()
federation_namespace = "playlists"

View File

@ -24,6 +24,7 @@ from funkwhale_api.common import validators as common_validators
from funkwhale_api.federation import keys
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
def get_token(length=5):
@ -457,6 +458,22 @@ def create_actor(user, **kwargs):
return federation_models.Actor.objects.create(user=user, **args)
def create_user_libraries(user):
for privacy_level, l in music_models.LIBRARY_PRIVACY_LEVEL_CHOICES:
music_models.Library.objects.create(
actor=user.actor,
privacy_level=privacy_level,
name=privacy_level,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:playlists-detail",
kwargs={"uuid": new_uuid},
)
),
)
@receiver(ldap_populate_user)
def init_ldap_user(sender, user, ldap_user, **kwargs):
if not user.actor:

View File

@ -114,7 +114,7 @@ class RegisterSerializer(RS):
user_request_id=user_request.pk,
new_status=user_request.status,
)
models.create_user_libraries(user)
return user

View File

@ -1,5 +1,3 @@
import uuid
import pytest
from django.db.models import Q
from django.urls import reverse
@ -246,9 +244,6 @@ def test_should_reject(factories, params, policy_kwargs, expected):
def test_get_actors_from_audience_urls(settings, db):
settings.FEDERATION_HOSTNAME = "federation.hostname"
library_uuid1 = uuid.uuid4()
library_uuid2 = uuid.uuid4()
urls = [
"https://wrong.url",
"https://federation.hostname"
@ -257,21 +252,15 @@ def test_get_actors_from_audience_urls(settings, db):
+ reverse("federation:actors-detail", kwargs={"preferred_username": "alice"}),
"https://federation.hostname"
+ reverse("federation:actors-detail", kwargs={"preferred_username": "bob"}),
"https://federation.hostname"
+ reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid1}),
"https://federation.hostname"
+ reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid2}),
"https://federation.hostname",
activity.PUBLIC_ADDRESS,
]
followed_query = Q(target__followers_url=urls[0])
for url in urls[1:-1]:
followed_query |= Q(target__followers_url=url)
actor_follows = models.Follow.objects.filter(followed_query, approved=True)
library_follows = models.LibraryFollow.objects.filter(followed_query, approved=True)
expected = models.Actor.objects.filter(
Q(fid__in=urls[0:-1])
| Q(pk__in=actor_follows.values_list("actor", flat=True))
| Q(pk__in=library_follows.values_list("actor", flat=True))
Q(fid__in=urls[0:-1]) | Q(pk__in=actor_follows.values_list("actor", flat=True))
)
assert str(activity.get_actors_from_audience(urls).query) == str(expected.query)
@ -478,17 +467,9 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
)
remote_actor3 = factories["federation.Actor"](shared_inbox_url=None)
remote_actor4 = factories["federation.Actor"]()
library = factories["music.Library"]()
library_follower_local = factories["federation.LibraryFollow"](
target=library, actor__local=True, approved=True
).actor
library_follower_remote = factories["federation.LibraryFollow"](
target=library, actor__local=False, approved=True
).actor
# follow not approved
factories["federation.LibraryFollow"](
target=library, actor__local=False, approved=False
factories["federation.Follow"](
target=remote_actor3, actor__local=False, approved=False
)
followed_actor = factories["federation.Actor"]()
@ -511,7 +492,6 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
remote_actor2,
remote_actor3,
activity.PUBLIC_ADDRESS,
{"type": "followers", "target": library},
{"type": "followers", "target": followed_actor},
{"type": "actor_inbox", "actor": remote_actor4},
]
@ -524,7 +504,6 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
models.InboxItem(actor=local_actor1, type="to"),
models.InboxItem(actor=local_actor2, type="to"),
models.InboxItem(actor=local_actor3, type="to"),
models.InboxItem(actor=library_follower_local, type="to"),
models.InboxItem(actor=actor_follower_local, type="to"),
],
key=lambda v: v.actor.pk,
@ -535,7 +514,6 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
models.Delivery(inbox_url=remote_actor1.shared_inbox_url),
models.Delivery(inbox_url=remote_actor3.inbox_url),
models.Delivery(inbox_url=remote_actor4.inbox_url),
models.Delivery(inbox_url=library_follower_remote.inbox_url),
models.Delivery(inbox_url=actor_follower_remote.inbox_url),
],
key=lambda v: v.inbox_url,
@ -549,7 +527,6 @@ def test_prepare_deliveries_and_inbox_items(factories, preferences):
remote_actor2.fid,
remote_actor3.fid,
activity.PUBLIC_ADDRESS,
library.followers_url,
followed_actor.followers_url,
remote_actor4.fid,
]

View File

@ -12,7 +12,6 @@ def test_library_serializer(factories, to_api_date):
"uuid": str(library.uuid),
"actor": serializers.APIActorSerializer(library.actor).data,
"name": library.name,
"description": library.description,
"creation_date": to_api_date(library.creation_date),
"uploads_count": library.uploads_count,
"privacy_level": library.privacy_level,

View File

@ -29,7 +29,7 @@ def test_fix_fids_no_dry_run(factories, mocker, queryset_equal_queries):
(music_models.Album, ["fid"]),
(music_models.Track, ["fid"]),
(music_models.Upload, ["fid"]),
(music_models.Library, ["fid", "followers_url"]),
(music_models.Library, ["fid"]),
(
federation_models.Actor,
[

View File

@ -370,7 +370,7 @@ def test_outbox_create_audio(factories, mocker):
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": upload.library}]
expected["to"] = [{"type": "followers", "target": upload.library.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == upload.library.actor
@ -685,7 +685,7 @@ def test_outbox_delete_audio(factories):
{"type": "Delete", "object": {"type": "Audio", "id": [upload.fid]}}
).data
expected["to"] = [{"type": "followers", "target": upload.library}]
expected["to"] = [{"type": "followers", "target": upload.library.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == upload.library.actor

View File

@ -548,13 +548,11 @@ def test_music_library_serializer_to_ap(factories):
"type": "Library",
"id": library.fid,
"name": library.name,
"summary": library.description,
"attributedTo": library.actor.fid,
"totalItems": 0,
"current": library.fid + "?page=1",
"last": library.fid + "?page=1",
"first": library.fid + "?page=1",
"followers": library.followers_url,
}
assert serializer.data == expected
@ -569,10 +567,8 @@ def test_music_library_serializer_from_public(factories, mocker):
"@context": jsonld.get_default_context(),
"audience": "https://www.w3.org/ns/activitystreams#Public",
"name": "Hello",
"summary": "World",
"type": "Library",
"id": "https://library.id",
"followers": "https://library.id/followers",
"attributedTo": actor.fid,
"totalItems": 12,
"first": "https://library.id?page=1",
@ -589,8 +585,6 @@ def test_music_library_serializer_from_public(factories, mocker):
assert library.uploads_count == data["totalItems"]
assert library.privacy_level == "everyone"
assert library.name == "Hello"
assert library.description == "World"
assert library.followers_url == data["followers"]
retrieve.assert_called_once_with(
actor.fid,
@ -609,10 +603,8 @@ def test_music_library_serializer_from_private(factories, mocker):
"@context": jsonld.get_default_context(),
"audience": "",
"name": "Hello",
"summary": "World",
"type": "Library",
"id": "https://library.id",
"followers": "https://library.id/followers",
"attributedTo": actor.fid,
"totalItems": 12,
"first": "https://library.id?page=1",
@ -629,8 +621,6 @@ def test_music_library_serializer_from_private(factories, mocker):
assert library.uploads_count == data["totalItems"]
assert library.privacy_level == "me"
assert library.name == "Hello"
assert library.description == "World"
assert library.followers_url == data["followers"]
retrieve.assert_called_once_with(
actor.fid,
actor=None,
@ -647,10 +637,8 @@ def test_music_library_serializer_from_ap_update(factories, mocker):
"@context": jsonld.get_default_context(),
"audience": "https://www.w3.org/ns/activitystreams#Public",
"name": "Hello",
"summary": "World",
"type": "Library",
"id": library.fid,
"followers": "https://library.id/followers",
"attributedTo": actor.fid,
"totalItems": 12,
"first": "https://library.id?page=1",
@ -666,8 +654,6 @@ def test_music_library_serializer_from_ap_update(factories, mocker):
assert library.uploads_count == data["totalItems"]
assert library.privacy_level == "everyone"
assert library.name == "Hello"
assert library.description == "World"
assert library.followers_url == data["followers"]
def test_activity_pub_artist_serializer_to_ap(factories):

View File

@ -219,7 +219,6 @@ def test_music_library_retrieve_page_public(factories, api_client):
"actor": library.actor,
"page": Paginator([upload], 1).page(1),
"name": library.name,
"summary": library.description,
}
).data

View File

@ -447,16 +447,13 @@ def test_manage_library_serializer(factories, now, to_api_date):
"fid": library.fid,
"url": library.url,
"uuid": str(library.uuid),
"followers_url": library.followers_url,
"domain": library.domain_name,
"is_local": library.is_local,
"name": library.name,
"description": library.description,
"privacy_level": library.privacy_level,
"creation_date": to_api_date(library.creation_date),
"actor": serializers.ManageBaseActorSerializer(library.actor).data,
"uploads_count": 44,
"followers_count": 42,
}
s = serializers.ManageLibrarySerializer(library)

View File

@ -1,3 +1,8 @@
from uuid import uuid4
import pytest
from django.utils.timezone import now
# this test is commented since it's very slow, but it can be useful for future development
# def test_pytest_plugin_initial(migrator):
# mapping_list = [
@ -72,3 +77,107 @@ def test_artist_credit_migration(migrator):
assert album_obj.artist_credit.all()[0].artist.pk == old_album.artist.pk
assert album_obj.artist_credit.all()[0].joinphrase == ""
assert album_obj.artist_credit.all()[0].credit == old_album.artist.name
@pytest.mark.django_db
def test_migrate_libraries_to_playlist(migrator):
music_initial_migration = (
"music",
"0059_remove_album_artist_remove_track_artist_artistcredit_and_more",
)
music_final_migration = ("music", "0061_migrate_libraries_to_playlist")
# Apply migrations
migrator.migrate(
[
music_initial_migration,
]
)
music_apps = migrator.loader.project_state([music_initial_migration]).apps
Playlist = music_apps.get_model("playlists", "Playlist")
LibraryFollow = music_apps.get_model("federation", "LibraryFollow")
Actor = music_apps.get_model("federation", "Actor")
Domain = music_apps.get_model("federation", "Domain")
Track = music_apps.get_model("music", "Track")
Library = music_apps.get_model("music", "Library")
Upload = music_apps.get_model("music", "Upload")
# Create data
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)
target_actor = Actor.objects.create(
name="Test Actor 2", domain=domain2, fid="http://test2.com/superduniquemanonmam"
)
library = Library.objects.create(
name="This should becane playlist name",
actor=target_actor,
creation_date=now(),
privacy_level="everyone",
uuid=uuid4(),
)
Track.objects.create()
Track.objects.create()
track = Track.objects.create()
track2 = Track.objects.create()
track3 = Track.objects.create()
uploads = [
Upload.objects.create(library=library, track=track),
Upload.objects.create(library=library, track=track2),
Upload.objects.create(library=library, track=track3),
]
library_follow = LibraryFollow.objects.create(
uuid=uuid4(),
target=library,
actor=actor,
approved=True,
creation_date=now(),
modification_date=now(),
)
# Perform migration
migrator.loader.build_graph()
migrator.migrate([music_final_migration])
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")
# Assertions
# Verify Playlist creation
playlist = Playlist.objects.get(name="This should becane playlist name")
assert playlist.actor.pk == library.actor.pk
assert playlist.creation_date == library.creation_date
assert playlist.privacy_level == library.privacy_level
assert playlist.description == library.description
# 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)
assert follow.actor.pk == actor.pk
assert follow.approved == library_follow.approved
# Verify LibraryFollow deletion and library creation
assert LibraryFollow.objects.count() == 0
# 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

View File

@ -263,7 +263,7 @@ def test_library(factories):
now = timezone.now()
actor = factories["federation.Actor"]()
library = factories["music.Library"](
name="Hello world", description="hello", actor=actor, privacy_level="instance"
name="Hello world", actor=actor, privacy_level="instance"
)
assert library.creation_date >= now

View File

@ -406,31 +406,6 @@ def test_track_upload_serializer(factories):
assert data == expected
@pytest.mark.parametrize(
"field,before,after",
[
("privacy_level", "me", "everyone"),
("name", "Before", "After"),
("description", "Before", "After"),
],
)
def test_update_library_privacy_level_broadcasts_to_followers(
factories, field, before, after, mocker
):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
library = factories["music.Library"](**{field: before})
serializer = serializers.LibraryForOwnerSerializer(
library, data={field: after}, partial=True
)
assert serializer.is_valid(raise_exception=True)
serializer.save()
dispatch.assert_called_once_with(
{"type": "Update", "object": {"type": "Library"}}, context={"library": library}
)
def test_upload_with_channel(factories, uploaded_audio_file):
channel = factories["audio.Channel"](attributed_to__local=True)
user = channel.attributed_to.user

View File

@ -311,7 +311,6 @@ def test_library_library(spa_html, no_api_auth, client, factories, settings):
},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": library.name},
{"tag": "meta", "property": "og:description", "content": library.description},
{
"tag": "link",
"rel": "alternate",

View File

@ -640,25 +640,6 @@ def test_listen_transcode_in_place(
)
def test_user_can_create_library(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
url = reverse("api:v1:libraries-list")
response = logged_in_api_client.post(
url, {"name": "hello", "description": "world", "privacy_level": "me"}
)
library = actor.libraries.first()
assert response.status_code == 201
assert library.actor == actor
assert library.name == "hello"
assert library.description == "world"
assert library.privacy_level == "me"
assert library.fid == library.get_federation_id()
assert library.followers_url == library.fid + "/followers"
def test_user_can_list_their_library(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"](actor=actor)
@ -712,17 +693,7 @@ def test_user_cannot_delete_other_actors_library(factories, logged_in_api_client
url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid})
response = logged_in_api_client.delete(url)
assert response.status_code == 404
def test_library_delete_via_api_triggers_outbox(factories, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
library = factories["music.Library"]()
view = views.LibraryViewSet()
view.perform_destroy(library)
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": "Library"}}, context={"library": library}
)
assert response.status_code == 405
def test_user_cannot_get_other_not_playable_uploads(factories, logged_in_api_client):
@ -937,25 +908,6 @@ def test_user_can_patch_draft_upload_status_triggers_processing(
m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
def test_user_can_list_own_library_follows(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"](actor=actor)
another_library = factories["music.Library"](actor=actor)
follow = factories["federation.LibraryFollow"](target=library)
factories["federation.LibraryFollow"](target=another_library)
url = reverse("api:v1:libraries-follows", kwargs={"uuid": library.uuid})
response = logged_in_api_client.get(url)
assert response.data == {
"count": 1,
"next": None,
"previous": None,
"results": [federation_api_serializers.LibraryFollowSerializer(follow).data],
}
@pytest.mark.parametrize("entity", ["artist", "album", "track"])
def test_can_get_libraries_for_music_entities(
factories, api_client, entity, preferences
@ -1615,3 +1567,25 @@ def test_album_create_artist_credit(factories, logged_in_api_client):
url, {"artist": artist.pk, "title": "super album"}, format="json"
)
assert response.status_code == 204
def test_can_patch_upload_list(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)
factories["music.Library"](actor=actor, privacy_level="everyone")
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 == 200
assert upload.library.privacy_level == "everyone"

View File

@ -89,3 +89,30 @@ def test_playlist_serializer(factories, to_api_date):
serializer = serializers.PlaylistSerializer(playlist)
assert serializer.data == expected
# to do :
# @pytest.mark.parametrize(
# "field,before,after",
# [
# ("privacy_level", "me", "everyone"),
# ("name", "Before", "After"),
# ("description", "Before", "After"),
# ],
# )
# def test_update_playlist_privacy_level_broadcasts_to_followers(
# factories, field, before, after, mocker
# ):
# dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
# playlist = factories["playlists.Playlist"](**{field: before})
# serializer = serializers.PlaylistSerializer(
# playlist, data={field: after}, partial=True
# )
# assert serializer.is_valid(raise_exception=True)
# serializer.save()
# dispatch.assert_called_once_with(
# {"type": "Update", "object": {"type": "Library"}}, context={"library": library}
# )

View File

@ -4,6 +4,7 @@ import pytest
from django.urls import reverse
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
from funkwhale_api.users import models
@ -182,6 +183,18 @@ def test_creating_actor_from_user(factories, settings):
)
def test_creating_libraries_from_user(factories, settings):
user = factories["users.User"](username="Hello M. world", with_actor=True)
models.create_user_libraries(user)
for privacy_level, desc in music_models.LIBRARY_PRIVACY_LEVEL_CHOICES:
assert (
user.actor.libraries.filter(
name=privacy_level, privacy_level=privacy_level, actor=user.actor
).count()
== 1
)
def test_get_channels_groups(factories):
user = factories["users.User"](permission_library=True)

View File

@ -0,0 +1 @@
Drop libraries in favor of playlist (#2366)

View File

@ -23,7 +23,6 @@ services:
dockerfile: Dockerfile.debian
command: >
sh -c "
funkwhale-manage migrate &&
funkwhale-manage collectstatic --no-input &&
uvicorn --reload config.asgi:application --host 0.0.0.0 --port 5000 --reload-dir config/ --reload-dir funkwhale_api/
"

View File

@ -0,0 +1,20 @@
# Library drop in favor of playlist
## Issue
We now have playlist, use complained about library not being clearly defined.
## Proposed solution
## Backend
A new endpoint to move upload from one library to another : `PATCH` on `api/v2/uploads/bulk-update` with `[{"uuid": "uuid1", "privacy_level": "public"}]`
### Migration from Libraries to Playlist
New `description` field on playlist, to inherit from the `description` field of Library
Library Follows will be transformed to user follow.
The schedule_scan function of the library still exist and allow federation of audio content
During user creation, built-in libraries are generated automatically by `create_user_libraries`