Supporting multi-artist per tracks/albums ()

This commit is contained in:
petitminion 2024-08-29 14:11:35 +00:00
parent 007fe3b192
commit 3b5de1a32d
108 changed files with 3419 additions and 1277 deletions
api
changes/changelog.d
docs/specs/multi-artist
front/src

View File

@ -40,11 +40,23 @@ def combined_recent(limit, **kwargs):
def get_activity(user, limit=20): def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level") query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
querysets = [ querysets = [
Listening.objects.filter(query).select_related( Listening.objects.filter(query)
"track", "user", "track__artist", "track__album__artist" .select_related(
"track",
"user",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
), ),
TrackFavorite.objects.filter(query).select_related( TrackFavorite.objects.filter(query)
"track", "user", "track__artist", "track__album__artist" .select_related(
"track",
"user",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
), ),
] ]
records = combined_recent(limit=limit, querysets=querysets) records = combined_recent(limit=limit, querysets=querysets)

View File

@ -21,7 +21,11 @@ TAG_FILTER = common_filters.MultipleQueryFilter(method=filter_tags)
class ChannelFilter(moderation_filters.HiddenContentFilterSet): class ChannelFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["artist__name", "actor__summary", "actor__preferred_username"] search_fields=[
"artist_credit__artist__name",
"actor__summary",
"actor__preferred_username",
]
) )
tag = TAG_FILTER tag = TAG_FILTER
scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True)
@ -33,7 +37,7 @@ class ChannelFilter(moderation_filters.HiddenContentFilterSet):
# tuple-mapping retains order # tuple-mapping retains order
fields=( fields=(
("creation_date", "creation_date"), ("creation_date", "creation_date"),
("artist__modification_date", "modification_date"), ("artist_credit__artist__modification_date", "modification_date"),
("?", "random"), ("?", "random"),
) )
) )

View File

@ -26,6 +26,7 @@ from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks
from funkwhale_api.music.serializers import COVER_WRITE_FIELD, CoverField from funkwhale_api.music.serializers import COVER_WRITE_FIELD, CoverField
from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import serializers as tags_serializers from funkwhale_api.tags import serializers as tags_serializers
@ -246,11 +247,14 @@ class SimpleChannelArtistSerializer(serializers.Serializer):
description = common_serializers.ContentSerializer(allow_null=True, required=False) description = common_serializers.ContentSerializer(allow_null=True, required=False)
cover = CoverField(allow_null=True, required=False) cover = CoverField(allow_null=True, required=False)
channel = serializers.UUIDField(allow_null=True, required=False) channel = serializers.UUIDField(allow_null=True, required=False)
tracks_count = serializers.IntegerField(source="_tracks_count", required=False) tracks_count = serializers.SerializerMethodField(required=False)
tags = serializers.ListField( tags = serializers.ListField(
child=serializers.CharField(), source="_prefetched_tagged_items", required=False child=serializers.CharField(), source="_prefetched_tagged_items", required=False
) )
def get_tracks_count(self, o) -> int:
return getattr(o, "_tracks_count", 0)
class ChannelSerializer(serializers.ModelSerializer): class ChannelSerializer(serializers.ModelSerializer):
artist = SimpleChannelArtistSerializer() artist = SimpleChannelArtistSerializer()
@ -749,7 +753,7 @@ class RssFeedItemSerializer(serializers.Serializer):
else: else:
existing_track = ( existing_track = (
music_models.Track.objects.filter( music_models.Track.objects.filter(
uuid=expected_uuid, artist__channel=channel uuid=expected_uuid, artist_credit__artist__channel=channel
) )
.select_related("description", "attachment_cover") .select_related("description", "attachment_cover")
.first() .first()
@ -765,7 +769,6 @@ class RssFeedItemSerializer(serializers.Serializer):
"disc_number": validated_data.get("itunes_season", 1) or 1, "disc_number": validated_data.get("itunes_season", 1) or 1,
"position": validated_data.get("itunes_episode", 1) or 1, "position": validated_data.get("itunes_episode", 1) or 1,
"title": validated_data["title"], "title": validated_data["title"],
"artist": channel.artist,
} }
) )
if "rights" in validated_data: if "rights" in validated_data:
@ -801,6 +804,21 @@ class RssFeedItemSerializer(serializers.Serializer):
**track_kwargs, **track_kwargs,
defaults=track_defaults, defaults=track_defaults,
) )
# channel only have one artist so we can safely update artist_credit
defaults = {
"artist": channel.artist,
"credit": channel.artist.name,
"joinphrase": "",
}
query = (
Q(artist=channel.artist) & Q(credit=channel.artist.name) & Q(joinphrase="")
)
artist_credit = tasks.get_best_candidate_or_create(
music_models.ArtistCredit, query, defaults, ["artist", "joinphrase"]
)
track.artist_credit.set([artist_credit[0]])
# optimisation for reducing SQL queries, because we cannot use select_related with # optimisation for reducing SQL queries, because we cannot use select_related with
# update or create, so we restore the cache by hand # update or create, so we restore the cache by hand
if existing_track: if existing_track:

View File

@ -27,7 +27,7 @@ ARTIST_PREFETCH_QS = (
"attachment_cover", "attachment_cover",
) )
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
.annotate(_tracks_count=Count("tracks")) .annotate(_tracks_count=Count("artist_credit__tracks"))
) )
@ -103,7 +103,7 @@ class ChannelViewSet(
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action == "retrieve": if self.action == "retrieve":
queryset = queryset.annotate( queryset = queryset.annotate(
_downloads_count=Sum("artist__tracks__downloads_count") _downloads_count=Sum("artist__artist_credit__tracks__downloads_count")
) )
return queryset return queryset
@ -192,7 +192,6 @@ class ChannelViewSet(
if object.attributed_to == actors.get_service_actor(): if object.attributed_to == actors.get_service_actor():
# external feed, we redirect to the canonical one # external feed, we redirect to the canonical one
return http.HttpResponseRedirect(object.rss_url) return http.HttpResponseRedirect(object.rss_url)
uploads = ( uploads = (
object.library.uploads.playable_by(None) object.library.uploads.playable_by(None)
.prefetch_related( .prefetch_related(

View File

@ -68,22 +68,33 @@ def create_taggable_items(dependency):
CONFIG = [ CONFIG = [
{
"id": "artist_credit",
"model": music_models.ArtistCredit,
"factory": "music.ArtistCredit",
"factory_kwargs": {"joinphrase": ""},
"depends_on": [
{"field": "artist", "id": "artists", "default_factor": 0.5},
],
},
{ {
"id": "tracks", "id": "tracks",
"model": music_models.Track, "model": music_models.Track,
"factory": "music.Track", "factory": "music.Track",
"factory_kwargs": {"artist": None, "album": None}, "factory_kwargs": {"album": None},
"depends_on": [ "depends_on": [
{"field": "album", "id": "albums", "default_factor": 0.1}, {"field": "album", "id": "albums", "default_factor": 0.1},
{"field": "artist", "id": "artists", "default_factor": 0.05}, {"field": "artist_credit", "id": "artist_credit", "default_factor": 0.05},
], ],
}, },
{ {
"id": "albums", "id": "albums",
"model": music_models.Album, "model": music_models.Album,
"factory": "music.Album", "factory": "music.Album",
"factory_kwargs": {"artist": None}, "factory_kwargs": {},
"depends_on": [{"field": "artist", "id": "artists", "default_factor": 0.3}], "depends_on": [
{"field": "artist_credit", "id": "artist_credit", "default_factor": 0.3}
],
}, },
{"id": "artists", "model": music_models.Artist, "factory": "music.Artist"}, {"id": "artists", "model": music_models.Artist, "factory": "music.Artist"},
{ {
@ -310,12 +321,23 @@ class Command(BaseCommand):
candidates = list(queryset.values_list("pk", flat=True)) candidates = list(queryset.values_list("pk", flat=True))
picked_pks = [random.choice(candidates) for _ in objects] picked_pks = [random.choice(candidates) for _ in objects]
picked_objects = {o.pk: o for o in queryset.filter(pk__in=picked_pks)} picked_objects = {o.pk: o for o in queryset.filter(pk__in=picked_pks)}
saved_obj = []
for i, obj in enumerate(objects): for i, obj in enumerate(objects):
if create_dependencies: if create_dependencies:
value = random.choice(candidates) value = random.choice(candidates)
else: else:
value = picked_objects[picked_pks[i]] value = picked_objects[picked_pks[i]]
setattr(obj, dependency["field"], value) if dependency["field"] == "artist_credit":
obj.save()
obj.artist_credit.set([value])
saved_obj.append(obj)
else:
setattr(obj, dependency["field"], value)
if saved_obj:
return saved_obj
if not handler: if not handler:
objects = row["model"].objects.bulk_create(objects, batch_size=BATCH_SIZE) objects = row["model"].objects.bulk_create(objects, batch_size=BATCH_SIZE)
results[row["id"]] = objects results[row["id"]] = objects

View File

@ -43,9 +43,9 @@ def get_lb_listen(listening):
release_name = track.album.title release_name = track.album.title
if track.album.mbid: if track.album.mbid:
additional_info["release_mbid"] = str(track.album.mbid) additional_info["release_mbid"] = str(track.album.mbid)
mbids = [ac.artist.mbid for ac in track.artist_credit.all() if ac.artist.mbid]
if track.artist.mbid: if mbids:
additional_info["artist_mbids"] = [str(track.artist.mbid)] additional_info["artist_mbids"] = mbids
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
if upload: if upload:
@ -53,8 +53,8 @@ def get_lb_listen(listening):
return liblistenbrainz.Listen( return liblistenbrainz.Listen(
track_name=track.title, track_name=track.title,
artist_name=track.artist.name,
listened_at=listening.creation_date.timestamp(), listened_at=listening.creation_date.timestamp(),
artist_name=track.get_artist_credit_string,
release_name=release_name, release_name=release_name,
additional_info=additional_info, additional_info=additional_info,
) )

View File

@ -37,7 +37,7 @@ def get_payload(listening, api_key, conf):
# See https://github.com/krateng/maloja/blob/master/API.md # See https://github.com/krateng/maloja/blob/master/API.md
payload = { payload = {
"key": api_key, "key": api_key,
"artists": [track.artist.name], "artists": [artist.name for artist in track.artist_credit.get_artists_list()],
"title": track.title, "title": track.title,
"time": int(listening.creation_date.timestamp()), "time": int(listening.creation_date.timestamp()),
"nofix": bool(conf.get("nofix")), "nofix": bool(conf.get("nofix")),
@ -46,8 +46,10 @@ def get_payload(listening, api_key, conf):
if track.album: if track.album:
if track.album.title: if track.album.title:
payload["album"] = track.album.title payload["album"] = track.album.title
if track.album.artist: if track.album.artist_credit.all():
payload["albumartists"] = [track.album.artist.name] payload["albumartists"] = [
artist.name for artist in track.album.artist_credit.get_artists_list()
]
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
if upload: if upload:

View File

@ -84,7 +84,7 @@ def get_scrobble_payload(track, date, suffix="[0]"):
""" """
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
data = { data = {
f"a{suffix}": track.artist.name, f"a{suffix}": track.get_artist_credit_string,
f"t{suffix}": track.title, f"t{suffix}": track.title,
f"l{suffix}": upload.duration if upload else 0, f"l{suffix}": upload.duration if upload else 0,
f"b{suffix}": (track.album.title if track.album else "") or "", f"b{suffix}": (track.album.title if track.album else "") or "",
@ -103,7 +103,7 @@ def get_scrobble2_payload(track, date, suffix="[0]"):
""" """
upload = track.uploads.filter(duration__gte=0).first() upload = track.uploads.filter(duration__gte=0).first()
data = { data = {
"artist": track.artist.name, "artist": track.get_artist_credit_string,
"track": track.title, "track": track.title,
"chosenByUser": 1, "chosenByUser": 1,
} }

View File

@ -60,11 +60,20 @@ class TrackFavoriteViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(self.request.user, "user__privacy_level")
) )
tracks = Track.objects.with_playable_uploads( tracks = (
music_utils.get_actor_from_request(self.request) Track.objects.with_playable_uploads(
).select_related( music_utils.get_actor_from_request(self.request)
"artist", "album__artist", "attributed_to", "album__attachment_cover" )
.prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
)
.select_related(
"attributed_to",
"album__attachment_cover",
)
) )
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
return queryset return queryset

View File

@ -293,6 +293,7 @@ CONTEXTS = [
"Album": "fw:Album", "Album": "fw:Album",
"Track": "fw:Track", "Track": "fw:Track",
"Artist": "fw:Artist", "Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library", "Library": "fw:Library",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"}, "bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"}, "size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
@ -302,7 +303,16 @@ CONTEXTS = [
"track": {"@id": "fw:track", "@type": "@id"}, "track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"}, "cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"}, "album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"}, "artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
"artist_credit": {
"@id": "fw:artist_credit",
"@type": "@id",
"@container": "@list",
},
"joinphrase": {"@id": "fw:joinphrase", "@type": "xsd:string"},
"credit": {"@id": "fw:credit", "@type": "xsd:string"},
"index": {"@id": "fw:index", "@type": "xsd:nonNegativeInteger"},
"released": {"@id": "fw:released", "@type": "xsd:date"}, "released": {"@id": "fw:released", "@type": "xsd:date"},
"musicbrainzId": "fw:musicbrainzId", "musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"}, "license": {"@id": "fw:license", "@type": "@id"},

View File

@ -191,7 +191,6 @@ def prepare_for_serializer(payload, config, fallbacks={}):
value = noop value = noop
if not aliases: if not aliases:
continue continue
for a in aliases: for a in aliases:
try: try:
value = get_value( value = get_value(
@ -279,7 +278,6 @@ class JsonLdSerializer(serializers.Serializer):
for field in dereferenced_fields: for field in dereferenced_fields:
for i in get_ids(data[field]): for i in get_ids(data[field]):
dereferenced_ids.add(i) dereferenced_ids.add(i)
if dereferenced_ids: if dereferenced_ids:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@ -293,7 +293,7 @@ def inbox_delete_audio(payload, context):
upload_fids = [payload["object"]["id"]] upload_fids = [payload["object"]["id"]]
query = Q(fid__in=upload_fids) & ( query = Q(fid__in=upload_fids) & (
Q(library__actor=actor) | Q(track__artist__channel__actor=actor) Q(library__actor=actor) | Q(track__artist_credit__artist__channel__actor=actor)
) )
candidates = music_models.Upload.objects.filter(query) candidates = music_models.Upload.objects.filter(query)
@ -577,7 +577,9 @@ def inbox_delete_album(payload, context):
logger.debug("Discarding deletion of empty library") logger.debug("Discarding deletion of empty library")
return return
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor)) query = Q(fid=album_id) & (
Q(attributed_to=actor) | Q(artist_credit__artist__channel__actor=actor)
)
try: try:
album = music_models.Album.objects.get(query) album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist: except music_models.Album.DoesNotExist:
@ -590,9 +592,10 @@ def inbox_delete_album(payload, context):
@outbox.register({"type": "Delete", "object.type": "Album"}) @outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context): def outbox_delete_album(context):
album = context["album"] album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = ( actor = (
album.artist.channel.actor album_artist.channel.actor
if album.artist.get_channel() if album_artist.get_channel()
else album.attributed_to else album.attributed_to
) )
actor = actor or actors.get_service_actor() actor = actor or actors.get_service_actor()

View File

@ -6,6 +6,7 @@ import uuid
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
@ -1221,12 +1222,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
self.updateable_fields, validated_data, instance self.updateable_fields, validated_data, instance
) )
updated_fields = self.validate_updated_data(instance, updated_fields) updated_fields = self.validate_updated_data(instance, updated_fields)
set_ac = False
if "artist_credit" in updated_fields:
artist_credit = updated_fields.pop("artist_credit")
set_ac = True
if creating: if creating:
instance, created = self.Meta.model.objects.get_or_create( instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields fid=validated_data["id"], defaults=updated_fields
) )
if set_ac:
instance.artist_credit.set(artist_credit)
else: else:
music_tasks.update_library_entity(instance, updated_fields) obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []] tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags) tags_models.set_tags(instance, *tags)
@ -1288,7 +1299,6 @@ class ArtistSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
@ -1314,12 +1324,53 @@ class ArtistSerializer(MusicEntitySerializer):
create = MusicEntitySerializer.update_or_create create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data,
"joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
}
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
class AlbumSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False) released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField( artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
image = ImageSerializer( image = ImageSerializer(
allowed_mimetypes=["image/*"], allowed_mimetypes=["image/*"],
allow_null=True, allow_null=True,
@ -1332,7 +1383,7 @@ class AlbumSerializer(MusicEntitySerializer):
("musicbrainzId", "mbid"), ("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"), ("attributedTo", "attributed_to"),
("released", "release_date"), ("released", "release_date"),
("_artist", "artist"), ("artist_credit", "artist_credit"),
] ]
class Meta: class Meta:
@ -1341,13 +1392,13 @@ class AlbumSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Album", "type": "Album",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
@ -1361,42 +1412,40 @@ class AlbumSerializer(MusicEntitySerializer):
else None, else None,
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
if instance.artist.get_channel():
d["artists"] = [ data["artist_credit"] = ArtistCreditSerializer(
{ instance.artist_credit.all(),
"type": instance.artist.channel.actor.type, context={"include_ap_context": False},
"id": instance.artist.channel.actor.fid, many=True,
} ).data
] include_content(data, instance.description)
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
]
include_content(d, instance.description)
if instance.attachment_cover: if instance.attachment_cover:
include_image(d, instance.attachment_cover) include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
def validate(self, data): def validate(self, data):
validated_data = super().validate(data) validated_data = super().validate(data)
if not self.parent: if not self.parent:
artist_data = validated_data["artists"][0] artist_credit_data = validated_data["artist_credit"]
if artist_data.get("type", "Artist") == "Artist": if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
validated_data["_artist"] = utils.retrieve_ap_object( acs = []
artist_data["id"], for ac in validated_data["artist_credit"]:
actor=self.context.get("fetch_actor"), acs.append(
queryset=music_models.Artist, utils.retrieve_ap_object(
serializer_class=ArtistSerializer, ac["id"],
) actor=self.context.get("fetch_actor"),
queryset=music_models.ArtistCredit,
serializer_class=ArtistCreditSerializer,
)
)
validated_data["artist_credit"] = acs
else: else:
# we have an actor as an artist, so it's a channel # we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"]) actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["_artist"] = actor.channel.artist validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data return validated_data
@ -1406,7 +1455,7 @@ class AlbumSerializer(MusicEntitySerializer):
class TrackSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False) disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer() album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
@ -1434,7 +1483,7 @@ class TrackSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"album": jsonld.first_obj(contexts.FW.album), "album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright), "copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc), "disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license), "license": jsonld.first_id(contexts.FW.license),
@ -1444,7 +1493,7 @@ class TrackSerializer(MusicEntitySerializer):
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Track", "type": "Track",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
@ -1456,11 +1505,11 @@ class TrackSerializer(MusicEntitySerializer):
if instance.local_license if instance.local_license
else None, else None,
"copyright": instance.copyright if instance.copyright else None, "copyright": instance.copyright if instance.copyright else None,
"artists": [ "artist_credit": ArtistCreditSerializer(
ArtistSerializer( instance.artist_credit.all(),
instance.artist, context={"include_ap_context": False} context={"include_ap_context": False},
).data many=True,
], ).data,
"album": AlbumSerializer( "album": AlbumSerializer(
instance.album, context={"include_ap_context": False} instance.album, context={"include_ap_context": False}
).data, ).data,
@ -1469,12 +1518,13 @@ class TrackSerializer(MusicEntitySerializer):
else None, else None,
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(data, instance.description)
include_image(d, instance.attachment_cover) include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
@ -1490,18 +1540,21 @@ class TrackSerializer(MusicEntitySerializer):
validated_data, "album.attributedTo", permissive=True validated_data, "album.attributedTo", permissive=True
) )
) )
artists = ( artist_credit = (
common_utils.recursive_getattr(validated_data, "artists", permissive=True)
or []
)
album_artists = (
common_utils.recursive_getattr( common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True validated_data, "artist_credit", permissive=True
) )
or [] or []
) )
for artist in artists + album_artists: album_artists_credit = (
actors_to_fetch.add(artist.get("attributedTo")) common_utils.recursive_getattr(
validated_data, "album.artist_credit", permissive=True
)
or []
)
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch: for url in actors_to_fetch:
if not url: if not url:
@ -1514,8 +1567,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity") from_activity = self.context.get("activity")
if from_activity: if from_activity:
metadata["from_activity_id"] = from_activity.pk metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True) track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track return track
def update(self, obj, validated_data): def update(self, obj, validated_data):
@ -1780,7 +1834,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.filter(track__artist=channel.artist), .filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection", "type": "OrderedCollection",
} }
r = super().to_representation(conf) r = super().to_representation(conf)
@ -1850,7 +1904,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
actor=actors.get_service_actor(), actor=actors.get_service_actor(),
serializer_class=AlbumSerializer, serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter( queryset=music_models.Album.objects.filter(
artist__channel=self.context["channel"] artist_credit__artist__channel=self.context["channel"]
), ),
) )
@ -1929,7 +1983,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
now = timezone.now() now = timezone.now()
track_defaults = { track_defaults = {
"fid": validated_data["id"], "fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1), "position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1), "disc_number": validated_data.get("disc", 1),
"title": validated_data["name"], "title": validated_data["name"],
@ -1942,9 +1995,32 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
track_defaults["license"] = licenses.match(validated_data["license"]) track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create( track, created = music_models.Track.objects.update_or_create(
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults fid=validated_data["id"],
defaults=track_defaults,
) )
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data: if "image" in validated_data:
new_value = self.validated_data["image"] new_value = self.validated_data["image"]
common_utils.attach_file( common_utils.attach_file(

View File

@ -17,6 +17,7 @@ router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads") music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists") music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums") music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")

View File

@ -161,7 +161,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related("library__channel__actor", "track__artist"), .prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer, "item_serializer": serializers.ChannelCreateUploadSerializer,
} }
return get_collection_response( return get_collection_response(
@ -290,21 +292,21 @@ class MusicLibraryViewSet(
Prefetch( Prefetch(
"track", "track",
queryset=music_models.Track.objects.select_related( queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover", "attachment_cover",
"album__attributed_to", "album__attributed_to",
"attributed_to", "attributed_to",
"album__attachment_cover", "album__attachment_cover",
"album__artist__attachment_cover",
"description", "description",
).prefetch_related( ).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag", "tagged_items__tag",
"album__tagged_items__tag", "album__tagged_items__tag",
"album__artist__tagged_items__tag", "album__artist_credit__artist__tagged_items__tag",
"artist__tagged_items__tag", "album__artist_credit__artist__attachment_cover",
"artist__description", "artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description", "album__description",
), ),
) )
@ -331,15 +333,20 @@ class MusicUploadViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related( queryset = (
"library__actor", music_models.Upload.objects.local()
"track__artist", .select_related(
"track__album__artist", "library__actor",
"track__description", "track__description",
"track__album__attachment_cover", "track__album__attachment_cover",
"track__album__artist__attachment_cover", "track__attachment_cover",
"track__artist__attachment_cover", )
"track__attachment_cover", .prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
"track__album__artist_credit__artist__attachment_cover",
"track__artist_credit__artist__attachment_cover",
)
) )
serializer_class = serializers.UploadSerializer serializer_class = serializers.UploadSerializer
lookup_field = "uuid" lookup_field = "uuid"
@ -393,13 +400,35 @@ class MusicArtistViewSet(
return response.Response(serializer.data) return response.Response(serializer.data)
class MusicArtistCreditViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.ArtistCredit.objects.local().prefetch_related("artist")
serializer_class = serializers.ArtistCreditSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicAlbumViewSet( class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related( queryset = (
"artist__description", "description", "artist__attachment_cover" music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
) )
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
@ -418,16 +447,22 @@ class MusicTrackViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( queryset = (
"album__artist", music_models.Track.objects.local()
"album__description", .select_related(
"artist__description", "album__description",
"description", "description",
"attachment_cover", "attachment_cover",
"album__artist__attachment_cover", "album__attachment_cover",
"album__attachment_cover", )
"artist__attachment_cover", .prefetch_related(
"album__artist_credit__artist",
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
"album__artist_credit__artist__attachment_cover",
)
) )
serializer_class = serializers.TrackSerializer serializer_class = serializers.TrackSerializer
lookup_field = "uuid" lookup_field = "uuid"

View File

@ -51,10 +51,16 @@ class ListeningViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(self.request.user, "user__privacy_level")
) )
tracks = Track.objects.with_playable_uploads( tracks = (
music_utils.get_actor_from_request(self.request) Track.objects.with_playable_uploads(
).select_related( music_utils.get_actor_from_request(self.request)
"artist", "album__artist", "attributed_to", "artist__attachment_cover" )
.prefetch_related(
"artist_credit",
"album__artist_credit__artist",
"artist_credit__artist__attachment_cover",
)
.select_related("attributed_to")
) )
return queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))

View File

@ -37,7 +37,7 @@ def get_content():
def get_top_music_categories(): def get_top_music_categories():
return ( return (
models.Track.objects.filter(artist__content_category="music") models.Track.objects.filter(artist_credit__artist__content_category="music")
.exclude(tagged_items__tag_id=None) .exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name")) .values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name")) .annotate(count=Count("name"))
@ -47,7 +47,7 @@ def get_top_music_categories():
def get_top_podcast_categories(): def get_top_podcast_categories():
return ( return (
models.Track.objects.filter(artist__content_category="podcast") models.Track.objects.filter(artist_credit__artist__content_category="podcast")
.exclude(tagged_items__tag_id=None) .exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name")) .values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name")) .annotate(count=Count("name"))

View File

@ -97,12 +97,15 @@ class ManageAlbumFilterSet(filters.FilterSet):
search_fields={ search_fields={
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
}, },
filter_fields={ filter_fields={
"uuid": {"to": "uuid"}, "uuid": {"to": "uuid"},
"artist_id": {"to": "artist_id", "field": forms.IntegerField()}, "artist_id": {
"to": "artist_credit__artist_id",
"field": forms.IntegerField(),
},
"domain": { "domain": {
"handler": lambda v: federation_utils.get_domain_query_from_url(v) "handler": lambda v: federation_utils.get_domain_query_from_url(v)
}, },
@ -118,7 +121,7 @@ class ManageAlbumFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ["title", "mbid", "fid", "artist"] fields = ["title", "mbid", "fid", "artist_credit"]
class ManageTrackFilterSet(filters.FilterSet): class ManageTrackFilterSet(filters.FilterSet):
@ -128,9 +131,9 @@ class ManageTrackFilterSet(filters.FilterSet):
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"album": {"to": "album__title"}, "album": {"to": "album__title"},
"album_artist": {"to": "album__artist__name"}, "album_artist": {"to": "album__artist_credit__artist__name"},
"copyright": {"to": "copyright"}, "copyright": {"to": "copyright"},
}, },
filter_fields={ filter_fields={
@ -157,7 +160,7 @@ class ManageTrackFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ["title", "mbid", "fid", "artist", "album", "license"] fields = ["title", "mbid", "fid", "artist_credit", "album", "license"]
class ManageLibraryFilterSet(filters.FilterSet): class ManageLibraryFilterSet(filters.FilterSet):

View File

@ -451,17 +451,25 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass pass
class ManageNestedArtistCreditSerializer(ManageBaseArtistSerializer):
artist = ManageNestedArtistSerializer()
class Meta:
model = music_models.ArtistCredit
fields = ["artist"]
class ManageAlbumSerializer( class ManageAlbumSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer
): ):
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + [ fields = ManageBaseAlbumSerializer.Meta.fields + [
"artist", "artist_credit",
"attributed_to", "attributed_to",
"tags", "tags",
"tracks_count", "tracks_count",
@ -477,17 +485,17 @@ class ManageAlbumSerializer(
class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] fields = ManageBaseAlbumSerializer.Meta.fields + ["artist_credit"]
class ManageTrackSerializer( class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
): ):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
album = ManageTrackAlbumSerializer(allow_null=True) album = ManageTrackAlbumSerializer(allow_null=True)
attributed_to = ManageBaseActorSerializer(allow_null=True) attributed_to = ManageBaseActorSerializer(allow_null=True)
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
@ -497,7 +505,7 @@ class ManageTrackSerializer(
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ManageNestedTrackSerializer.Meta.fields + [ fields = ManageNestedTrackSerializer.Meta.fields + [
"artist", "artist_credit",
"album", "album",
"attributed_to", "attributed_to",
"uploads_count", "uploads_count",

View File

@ -84,8 +84,8 @@ class ManageArtistViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks", distinct=True)) .annotate(_tracks_count=Count("artist_credit__tracks", distinct=True))
.annotate(_albums_count=Count("albums", distinct=True)) .annotate(_albums_count=Count("artist_credit__albums", distinct=True))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
) )
serializer_class = serializers.ManageArtistSerializer serializer_class = serializers.ManageArtistSerializer
@ -98,7 +98,7 @@ class ManageArtistViewSet(
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
artist = self.get_object() artist = self.get_object()
tracks = music_models.Track.objects.filter( tracks = music_models.Track.objects.filter(
Q(artist=artist) | Q(album__artist=artist) Q(artist_credit__artist=artist) | Q(album__artist_credit__artist=artist)
) )
data = get_stats(tracks, artist) data = get_stats(tracks, artist)
return response.Response(data, status=200) return response.Response(data, status=200)
@ -128,8 +128,8 @@ class ManageAlbumViewSet(
queryset = ( queryset = (
music_models.Album.objects.all() music_models.Album.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "artist", "attachment_cover") .select_related("attributed_to", "attachment_cover")
.prefetch_related("tracks") .prefetch_related("tracks", "artist_credit__artist")
) )
serializer_class = serializers.ManageAlbumSerializer serializer_class = serializers.ManageAlbumSerializer
filterset_class = filters.ManageAlbumFilterSet filterset_class = filters.ManageAlbumFilterSet
@ -177,10 +177,10 @@ class ManageTrackViewSet(
queryset = ( queryset = (
music_models.Track.objects.all() music_models.Track.objects.all()
.order_by("-id") .order_by("-id")
.select_related( .prefetch_related(
"attributed_to", "attributed_to",
"artist", "artist_credit",
"album__artist", "album__artist_credit",
"album__attachment_cover", "album__attachment_cover",
"attachment_cover", "attachment_cover",
) )
@ -273,11 +273,11 @@ class ManageLibraryViewSet(
) )
artists = set( artists = set(
music_models.Album.objects.filter(pk__in=albums).values_list( music_models.Album.objects.filter(pk__in=albums).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) | set( ) | set(
music_models.Track.objects.filter(pk__in=tracks).values_list( music_models.Track.objects.filter(pk__in=tracks).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) )
@ -313,7 +313,11 @@ class ManageUploadViewSet(
queryset = ( queryset = (
music_models.Upload.objects.all() music_models.Upload.objects.all()
.order_by("-id") .order_by("-id")
.select_related("library__actor", "track__artist", "track__album__artist") .prefetch_related(
"library__actor",
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
) )
serializer_class = serializers.ManageUploadSerializer serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet filterset_class = filters.ManageUploadFilterSet
@ -703,8 +707,8 @@ class ManageChannelViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks")) .annotate(_tracks_count=Count("artist_credit__tracks"))
.annotate(_albums_count=Count("albums")) .annotate(_albums_count=Count("artist_credit__albums"))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
), ),
) )
@ -720,7 +724,8 @@ class ManageChannelViewSet(
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
channel = self.get_object() channel = self.get_object()
tracks = music_models.Track.objects.filter( tracks = music_models.Track.objects.filter(
Q(artist=channel.artist) | Q(album__artist=channel.artist) Q(artist_credit__artist=channel.artist)
| Q(album__artist_credit__artist=channel.artist)
) )
data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"]) data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"])
data["follows"] = channel.actor.received_follows.count() data["follows"] = channel.actor.received_follows.count()

View File

@ -4,11 +4,24 @@ from django_filters import rest_framework as filters
USER_FILTER_CONFIG = { USER_FILTER_CONFIG = {
"ARTIST": {"target_artist": ["pk"]}, "ARTIST": {"target_artist": ["pk"]},
"CHANNEL": {"target_artist": ["artist__pk"]}, "CHANNEL": {"target_artist": ["artist__pk"]},
"ALBUM": {"target_artist": ["artist__pk"]}, "ALBUM": {"target_artist": ["artist_credit__artist__pk"]},
"TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]}, "TRACK": {
"LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]}, "target_artist": [
"artist_credit__artist__pk",
"album__artist_credit__artist__pk",
]
},
"LISTENING": {
"target_artist": [
"track__album__artist_credit__artist__pk",
"track__artist_credit__artist__pk",
]
},
"TRACK_FAVORITE": { "TRACK_FAVORITE": {
"target_artist": ["track__album__artist__pk", "track__artist__pk"] "target_artist": [
"track__album__artist_credit__artist__pk",
"track__artist_credit__artist__pk",
]
}, },
} }

View File

@ -89,10 +89,29 @@ class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
] ]
@state_serializers.register(name="music.ArtistCredit")
class ArtistCreditStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
artist = ArtistStateSerializer()
class Meta:
model = music_models.ArtistCredit
fields = [
"id",
"credit",
"mbid",
"fid",
"creation_date",
"uuid",
"artist",
"joinphrase",
"index",
]
@state_serializers.register(name="music.Album") @state_serializers.register(name="music.Album")
class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer): class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD tags = TAGS_FIELD
artist = ArtistStateSerializer() artist_credit = ArtistCreditStateSerializer(many=True)
class Meta: class Meta:
model = music_models.Album model = music_models.Album
@ -103,7 +122,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid", "fid",
"creation_date", "creation_date",
"uuid", "uuid",
"artist", "artist_credit",
"release_date", "release_date",
"tags", "tags",
"description", "description",
@ -113,7 +132,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
@state_serializers.register(name="music.Track") @state_serializers.register(name="music.Track")
class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer): class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD tags = TAGS_FIELD
artist = ArtistStateSerializer() artist_credit = ArtistCreditStateSerializer(many=True)
album = AlbumStateSerializer() album = AlbumStateSerializer()
class Meta: class Meta:
@ -125,7 +144,7 @@ class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid", "fid",
"creation_date", "creation_date",
"uuid", "uuid",
"artist", "artist_credit",
"album", "album",
"disc_number", "disc_number",
"position", "position",
@ -230,6 +249,7 @@ TARGET_CONFIG = {
"id_field": serializers.UUIDField(), "id_field": serializers.UUIDField(),
}, },
"artist": {"queryset": music_models.Artist.objects.all()}, "artist": {"queryset": music_models.Artist.objects.all()},
"artist_credit": {"queryset": music_models.ArtistCredit.objects.all()},
"album": {"queryset": music_models.Album.objects.all()}, "album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()}, "track": {"queryset": music_models.Track.objects.all()},
"library": { "library": {

View File

@ -3,6 +3,17 @@ from funkwhale_api.common import admin
from . import models from . import models
@admin.register(models.ArtistCredit)
class ArtistCreditAdmin(admin.ModelAdmin):
list_display = [
"artist",
"credit",
"joinphrase",
"creation_date",
]
search_fields = ["artist__name", "credit"]
@admin.register(models.Artist) @admin.register(models.Artist)
class ArtistAdmin(admin.ModelAdmin): class ArtistAdmin(admin.ModelAdmin):
list_display = ["name", "mbid", "creation_date", "modification_date"] list_display = ["name", "mbid", "creation_date", "modification_date"]
@ -11,16 +22,18 @@ class ArtistAdmin(admin.ModelAdmin):
@admin.register(models.Album) @admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "mbid", "release_date", "creation_date"] list_display = ["title", "mbid", "release_date", "creation_date"]
search_fields = ["title", "artist__name", "mbid"] search_fields = ["title", "mbid"]
list_select_related = True list_select_related = True
@admin.register(models.Track) @admin.register(models.Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
list_display = ["title", "artist", "album", "mbid"] list_display = ["title", "album", "mbid", "artist"]
search_fields = ["title", "artist__name", "album__title", "mbid"] search_fields = ["title", "album__title", "mbid"]
list_select_related = ["album__artist", "artist"]
def artist(self, obj):
return obj.get_artist_credit_string
@admin.register(models.TrackActor) @admin.register(models.TrackActor)

View File

@ -1,3 +1,4 @@
from django.forms import widgets
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
@ -159,3 +160,45 @@ class ReleaseDate(types.BooleanPreference):
verbose_name = "Release date Filter" verbose_name = "Release date Filter"
help_text = "Only content with a release date will be displayed" help_text = "Only content with a release date will be displayed"
default = False default = False
@global_preferences_registry.register
class JoinPhrases(types.StringPreference):
show_in_api = True
section = music
name = "join_phrases"
verbose_name = "Join Phrases"
help_text = (
"Used by the artist parser to create multiples artists in case the metadata "
"is a single string. BE WARNED, changing the order or the values can break the parser in unexpected ways. "
"It's MANDATORY to escape dots and to put doted variation before because the first match is used "
r"(example : `|feat\.|ft\.|feat|` and not `feat|feat\.|ft\.|feat`.). ORDER is really important "
"(says an anarchist). To avoid artist duplication and wrongly parsed artist data "
"it's recommended to tag files with Musicbrainz Picard. "
)
default = (
r"featuring | feat\. | ft\. | feat | with | and | & | vs\. | \| | \||\| |\|| , | ,|, |,|"
r" ; | ;|; |;| versus | vs | \( | \(|\( |\(| Remix\) |Remix\) | Remix\)| \) | \)|\) |\)| x |"
"accompanied by | alongside | together with | collaboration with | featuring special guest |"
"joined by | joined with | featuring guest | introducing | accompanied by | performed by | performed with |"
"performed by and | and | featuring | with | presenting | accompanied by | and special guest |"
"featuring special guests | featuring and | featuring & | and featuring "
)
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class DefaultJoinPhrases(types.StringPreference):
show_in_api = True
section = music
name = "default_join_phrase"
verbose_name = "Default Join Phrase"
help_text = (
"The default join phrase used by artist parser"
"For example: `artists = [artist1, Artist2]` will be displayed has : artist1.name, artis2.name"
"Changing this value will not update already parsed artists"
)
default = ", "
widget = widgets.Textarea
field_kwargs = {"required": False}

View File

@ -1,6 +1,8 @@
import os import os
from urllib.parse import urlparse
import factory import factory
from django.conf import settings
from funkwhale_api.common import factories as common_factories from funkwhale_api.common import factories as common_factories
from funkwhale_api.factories import NoUpdateOnCreate, registry from funkwhale_api.factories import NoUpdateOnCreate, registry
@ -62,7 +64,7 @@ class ArtistFactory(
name = factory.Faker("name") name = factory.Faker("name")
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist") playable = playable_factory("track__album__artist_credit__artist")
class Meta: class Meta:
model = "music.Artist" model = "music.Artist"
@ -77,6 +79,16 @@ class ArtistFactory(
) )
@registry.register
class ArtistCreditFactory(factory.django.DjangoModelFactory):
artist = factory.SubFactory(ArtistFactory)
credit = factory.LazyAttribute(lambda obj: obj.artist.name)
joinphrase = ""
class Meta:
model = "music.ArtistCredit"
@registry.register @registry.register
class AlbumFactory( class AlbumFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
@ -84,7 +96,6 @@ class AlbumFactory(
title = factory.Faker("sentence", nb_words=3) title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
release_date = factory.Faker("date_object") release_date = factory.Faker("date_object")
artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker("uuid4") release_group_id = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
playable = playable_factory("track__album") playable = playable_factory("track__album")
@ -96,14 +107,22 @@ class AlbumFactory(
attributed = factory.Trait( attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory) attributed_to=factory.SubFactory(federation_factories.ActorFactory)
) )
local = factory.Trait( local = factory.Trait(
fid=factory.Faker("federation_url", local=True), artist__local=True fid=factory.Faker("federation_url", local=True),
) )
with_cover = factory.Trait( with_cover = factory.Trait(
attachment_cover=factory.SubFactory(common_factories.AttachmentFactory) attachment_cover=factory.SubFactory(common_factories.AttachmentFactory)
) )
@factory.post_generation
def artist_credit(self, create, extracted, **kwargs):
if urlparse(self.fid).netloc == settings.FEDERATION_HOSTNAME:
kwargs["artist__local"] = True
if extracted:
self.artist_credit.add(extracted)
if create:
self.artist_credit.add(ArtistCreditFactory(**kwargs))
@registry.register @registry.register
class TrackFactory( class TrackFactory(
@ -132,22 +151,29 @@ class TrackFactory(
) )
@factory.post_generation @factory.post_generation
def artist(self, created, extracted, **kwargs): def artist_credit(self, created, extracted, **kwargs):
""" """
A bit intricated, because we want to be able to specify a different A bit intricated, because we want to be able to specify a different
track artist with a fallback on album artist if nothing is specified. track artist with a fallback on album artist if nothing is specified.
And handle cases where build or build_batch are used (so no db calls) And handle cases where build or build_batch are used (so no db calls)
""" """
# needed to get a primary key on the track and album objects. The primary key is needed for many_to_many
if self.album:
self.album.save()
if not self.pk:
self.save()
if extracted: if extracted:
self.artist = extracted self.artist_credit.add(extracted)
elif kwargs: elif kwargs:
if created: if created:
self.artist = ArtistFactory(**kwargs) self.artist_credit.add(ArtistCreditFactory(**kwargs))
else: else:
self.artist = ArtistFactory.build(**kwargs) self.artist_credit.add(ArtistCreditFactory.build(**kwargs))
elif self.album: elif self.album:
self.artist = self.album.artist self.artist_credit.set(self.album.artist_credit.all())
if created: if created:
self.save() self.save()
@ -195,7 +221,9 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
from funkwhale_api.audio import factories as audio_factories from funkwhale_api.audio import factories as audio_factories
audio_factories.ChannelFactory( audio_factories.ChannelFactory(
library=self.library, artist=self.track.artist, **kwargs library=self.library,
artist=self.track.artist_credit.all()[0].artist,
**kwargs
) )

View File

@ -100,9 +100,9 @@ class ArtistFilter(
tag = TAG_FILTER tag = TAG_FILTER
content_category = filters.CharFilter("content_category") content_category = filters.CharFilter("content_category")
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor", actor_field="artist_credit__tracks__uploads__library__actor",
distinct=True, distinct=True,
library_field="tracks__uploads__library", library_field="artist_credit__tracks__uploads__library",
) )
ordering = common_filters.CaseInsensitiveNameOrderingFilter( ordering = common_filters.CaseInsensitiveNameOrderingFilter(
fields=( fields=(
@ -128,14 +128,14 @@ class ArtistFilter(
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel" include_channels_field = "channel"
library_filter_field = "track__artist" library_filter_field = "track__artist_credit__artist"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct() return queryset.playable_by(actor, value).distinct()
def filter_has_albums(self, queryset, name, value): def filter_has_albums(self, queryset, name, value):
return queryset.filter(albums__isnull=not value) return queryset.filter(artist_credit__albums__isnull=not value)
def filter_has_mbid(self, queryset, name, value): def filter_has_mbid(self, queryset, name, value):
return queryset.filter(mbid__isnull=(not value)) return queryset.filter(mbid__isnull=(not value))
@ -149,8 +149,16 @@ class TrackFilter(
moderation_filters.HiddenContentFilterSet, moderation_filters.HiddenContentFilterSet,
): ):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["title", "album__title", "artist__name"], search_fields=[
fts_search_fields=["body_text", "artist__body_text", "album__body_text"], "title",
"album__title",
"artist_credit__artist__name",
],
fts_search_fields=[
"body_text",
"artist_credit__artist__body_text",
"album__body_text",
],
) )
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
tag = TAG_FILTER tag = TAG_FILTER
@ -173,8 +181,11 @@ class TrackFilter(
("size", "size"), ("size", "size"),
("position", "position"), ("position", "position"),
("disc_number", "disc_number"), ("disc_number", "disc_number"),
("artist__name", "artist__name"), ("artist_credit__artist__name", "artist_credit__artist__name"),
("artist__modification_date", "artist__modification_date"), (
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"), ("?", "random"),
("tag_matches", "related"), ("tag_matches", "related"),
) )
@ -205,16 +216,19 @@ class TrackFilter(
"mbid": ["exact"], "mbid": ["exact"],
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
include_channels_field = "artist__channel" include_channels_field = "artist_credit__artist__channel"
channel_filter_field = "track" channel_filter_field = "track"
library_filter_field = "track" library_filter_field = "track"
artist_credit_filter_field = "artist__credit__artist"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct() return queryset.playable_by(actor, value).distinct()
def filter_artist(self, queryset, name, value): def filter_artist(self, queryset, name, value):
return queryset.filter(Q(artist=value) | Q(album__artist=value)) return queryset.filter(
Q(artist_credit__artist=value) | Q(album__artist_credit__artist=value)
)
def filter_format(self, queryset, name, value): def filter_format(self, queryset, name, value):
mimetypes = [utils.get_type_from_ext(e) for e in value.split(",")] mimetypes = [utils.get_type_from_ext(e) for e in value.split(",")]
@ -238,8 +252,8 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
library = filters.CharFilter("library__uuid") library = filters.CharFilter("library__uuid")
channel = filters.CharFilter("library__channel__uuid") channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid") track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid") track_artist = filters.UUIDFilter("track__artist_credit__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid") album_artist = filters.UUIDFilter("track__album__artist_credit__artist__uuid")
library = filters.UUIDFilter("library__uuid") library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
@ -273,7 +287,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
"mimetype", "mimetype",
"import_reference", "import_reference",
] ]
include_channels_field = "track__artist__channel" include_channels_field = "track__artist_credit__artist__channel"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
@ -289,10 +303,10 @@ class AlbumFilter(
): ):
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["title", "artist__name"], search_fields=["title", "artist_credit__artist__name"],
fts_search_fields=["body_text", "artist__body_text"], fts_search_fields=["body_text", "artist_credit__artist__body_text"],
) )
content_category = filters.CharFilter("artist__content_category") content_category = filters.CharFilter("artist_credit__artist__content_category")
tag = TAG_FILTER tag = TAG_FILTER
scope = common_filters.ActorScopeFilter( scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor", actor_field="tracks__uploads__library__actor",
@ -305,7 +319,10 @@ class AlbumFilter(
("creation_date", "creation_date"), ("creation_date", "creation_date"),
("release_date", "release_date"), ("release_date", "release_date"),
("title", "title"), ("title", "title"),
("artist__modification_date", "artist__modification_date"), (
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"), ("?", "random"),
("tag_matches", "related"), ("tag_matches", "related"),
) )
@ -326,15 +343,18 @@ class AlbumFilter(
) )
has_release_date = filters.BooleanFilter( has_release_date = filters.BooleanFilter(
field_name="_", field_name="_", method="filter_has_release_date"
method="filter_has_release_date", )
artist = filters.ModelChoiceFilter(
field_name="_", method="filter_artist", queryset=models.Artist.objects.all()
) )
class Meta: class Meta:
model = models.Album model = models.Album
fields = ["artist", "mbid"] fields = ["artist_credit", "mbid"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
include_channels_field = "artist__channel" include_channels_field = "artist_credit__artist__channel"
channel_filter_field = "track__album" channel_filter_field = "track__album"
library_filter_field = "track__album" library_filter_field = "track__album"
@ -354,6 +374,9 @@ class AlbumFilter(
def filter_has_release_date(self, queryset, name, value): def filter_has_release_date(self, queryset, name, value):
return queryset.filter(release_date__isnull=(not value)) return queryset.filter(release_date__isnull=(not value))
def filter_artist(self, queryset, name, value):
return queryset.filter(artist_credit__artist=value)
class LibraryFilter(filters.FilterSet): class LibraryFilter(filters.FilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(

View File

@ -12,11 +12,14 @@ class Importer:
def load(self, cleaned_data, raw_data, import_hooks): def load(self, cleaned_data, raw_data, import_hooks):
mbid = cleaned_data.pop("mbid") mbid = cleaned_data.pop("mbid")
artists_credits = cleaned_data.pop("artist_credit", None)
# let's validate data, just in case # let's validate data, just in case
instance = self.model(**cleaned_data) instance = self.model(**cleaned_data)
exclude = EXCLUDE_VALIDATION.get(self.model.__name__, []) exclude = EXCLUDE_VALIDATION.get(self.model.__name__, [])
instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude) instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude)
m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0] m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0]
if artists_credits:
m.artist_credit.set(artists_credits)
for hook in import_hooks: for hook in import_hooks:
hook(m, cleaned_data, raw_data) hook(m, cleaned_data, raw_data)
return m return m
@ -47,4 +50,9 @@ class Mapping:
) )
registry = {"Artist": Importer, "Track": Importer, "Album": Importer} registry = {
"Artist": Importer,
"ArtistCredit": Importer,
"Track": Importer,
"Album": Importer,
}

View File

@ -700,10 +700,10 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
to_update = ( to_update = (
existing_candidates.in_place() existing_candidates.in_place()
.filter(source=source) .filter(source=source)
.select_related( .select_related("track__attributed_to")
"track__attributed_to", .prefetch_related(
"track__artist", "track__artist_credit__artist",
"track__album__artist", "track__album__artist_credit__artist",
) )
.first() .first()
) )
@ -839,9 +839,9 @@ def check_upload(stdout, upload):
" Cannot update track metadata, track belongs to someone else" " Cannot update track metadata, track belongs to someone else"
) )
else: else:
track = models.Track.objects.select_related("artist", "album__artist").get( track = models.Track.objects.prefetch_related(
pk=upload.track_id "artist_credit__artist", "album__artist_credit__artist"
) ).get(pk=upload.track_id)
try: try:
tasks.update_track_metadata(upload.get_metadata(), track) tasks.update_track_metadata(upload.get_metadata(), track)
except serializers.ValidationError as e: except serializers.ValidationError as e:

View File

@ -492,61 +492,76 @@ class ArtistField(serializers.Field):
return final return final
def to_internal_value(self, data): def _get_artist_credit_tuple(self, mbids, data):
# we have multiple values that can be separated by various separators from . import tasks
separators = [";", ","]
names_artists_credits_tuples = tasks.parse_credits(
data.get("names", ""), None, None
)
artist_artists_credits_tuples = tasks.parse_credits(
data.get("artists", ""), None, None
)
len_mbids = len(mbids)
if (
len(names_artists_credits_tuples) != len_mbids
and len(artist_artists_credits_tuples) != len_mbids
):
logger.warning(
"Error parsing artist data, not the same amount of mbids and parsed artists. \
Probably because the artist parser found more artists than there is."
)
if len(names_artists_credits_tuples) > len(artist_artists_credits_tuples):
return names_artists_credits_tuples
return artist_artists_credits_tuples
def _get_mbids(self, raw_mbids):
# we have multiple mbid values that can be separated by various separators
separators = [";", ",", "/"]
# we get a list like that if tagged via musicbrainz # we get a list like that if tagged via musicbrainz
# ae29aae4-abfb-4609-8f54-417b1f4d64cc; 3237b5a8-ae44-400c-aa6d-cea51f0b9074; # ae29aae4-abfb-4609-8f54-417b1f4d64cc; 3237b5a8-ae44-400c-aa6d-cea51f0b9074;
raw_mbids = data["mbids"]
used_separator = None
mbids = [raw_mbids] mbids = [raw_mbids]
if raw_mbids: if raw_mbids:
if "/" in raw_mbids:
# it's a featuring, we can't handle this now
mbids = []
else:
for separator in separators:
if separator in raw_mbids:
used_separator = separator
mbids = [m.strip() for m in raw_mbids.split(separator)]
break
# now, we split on artist names, using the same separator as the one used
# by mbids, if any
names = []
if data.get("artists", None):
for separator in separators: for separator in separators:
if separator in data["artists"]: if separator in raw_mbids:
names = [n.strip() for n in data["artists"].split(separator)] mbids = [m.strip() for m in raw_mbids.split(separator)]
break break
# corner case: 'album artist' field with only one artist but multiple names in 'artits' field return mbids
if (
not names def _format_artist_credit_list(self, artists_credits_tuples, mbids):
and data.get("names", None) final_artist_credits = []
and any(separator in data["names"] for separator in separators) for i, ac in enumerate(artists_credits_tuples):
): artist_credit = {
names = [n.strip() for n in data["names"].split(separators[0])] "credit": ac[0],
elif not names: "mbid": (mbids[i] if 0 <= i < len(mbids) else None),
names = [data["artists"]] "joinphrase": ac[1],
elif used_separator and mbids: "index": i,
names = [n.strip() for n in data["names"].split(used_separator)] }
else: final_artist_credits.append(artist_credit)
names = [data["names"]]
return final_artist_credits
def to_internal_value(self, data):
if (
self.context.get("strict", True)
and not data.get("artists", [])
and not data.get("names", [])
):
raise serializers.ValidationError("This field is required.")
mbids = self._get_mbids(data["mbids"])
# now, we split on artist names
artists_credits_tuples = self._get_artist_credit_tuple(mbids, data)
final_artist_credits = self._format_artist_credit_list(
artists_credits_tuples, mbids
)
final = []
for i, name in enumerate(names):
try:
mbid = mbids[i]
except IndexError:
mbid = None
artist = {"name": name, "mbid": mbid}
final.append(artist)
field = serializers.ListField( field = serializers.ListField(
child=ArtistSerializer(strict=self.context.get("strict", True)), child=ArtistSerializer(strict=self.context.get("strict", True)),
min_length=1, min_length=1,
) )
return field.to_internal_value(final) return field.to_internal_value(final_artist_credits)
class AlbumField(serializers.Field): class AlbumField(serializers.Field):
@ -565,16 +580,17 @@ class AlbumField(serializers.Field):
"release_date": data.get("date", None), "release_date": data.get("date", None),
"mbid": data.get("musicbrainz_albumid", None), "mbid": data.get("musicbrainz_albumid", None),
} }
artists_field = ArtistField(for_album=True) artist_credit_field = ArtistField(for_album=True)
payload = artists_field.get_value(data) payload = artist_credit_field.get_value(data)
try: try:
artists = artists_field.to_internal_value(payload) artist_credit = artist_credit_field.to_internal_value(payload)
except serializers.ValidationError as e: except serializers.ValidationError as e:
artists = [] artist_credit = []
logger.debug("Ignoring validation error on album artists: %s", e) logger.debug("Ignoring validation error on album artist_credit: %s", e)
album_serializer = AlbumSerializer(data=final) album_serializer = AlbumSerializer(data=final)
album_serializer.is_valid(raise_exception=True) album_serializer.is_valid(raise_exception=True)
album_serializer.validated_data["artists"] = artists album_serializer.validated_data["artist_credit"] = artist_credit
return album_serializer.validated_data return album_serializer.validated_data
@ -655,14 +671,17 @@ class MBIDField(serializers.UUIDField):
class ArtistSerializer(serializers.Serializer): class ArtistSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_null=True, allow_blank=True) credit = serializers.CharField(required=False, allow_null=True, allow_blank=True)
mbid = MBIDField() mbid = MBIDField()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.strict = kwargs.pop("strict", True) self.strict = kwargs.pop("strict", True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def validate_name(self, v): def validate_credit(self, v):
if self.strict and not v: if self.strict and not v:
raise serializers.ValidationError("This field is required.") raise serializers.ValidationError("This field is required.")
return v return v
@ -731,7 +750,7 @@ class TrackMetadataSerializer(serializers.Serializer):
description = DescriptionField(allow_null=True, allow_blank=True, required=False) description = DescriptionField(allow_null=True, allow_blank=True, required=False)
album = AlbumField() album = AlbumField()
artists = ArtistField() artist_credit = ArtistField()
cover_data = CoverDataField(required=False) cover_data = CoverDataField(required=False)
remove_blank_null_fields = [ remove_blank_null_fields = [

View File

@ -0,0 +1,128 @@
# Generated by Django 4.2.9 on 2024-03-16 00:36
import django.contrib.postgres.search
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
def skip(apps, schema_editor):
pass
def save_artist_credit(obj, ArtistCredit):
artist_credit, created = ArtistCredit.objects.get_or_create(
artist=obj.artist,
joinphrase="",
credit=obj.artist.name,
)
obj.artist_credit.set([artist_credit])
obj.save()
def set_all_artists_credit(apps, schema_editor):
Track = apps.get_model("music", "Track")
Album = apps.get_model("music", "Album")
ArtistCredit = apps.get_model("music", "ArtistCredit")
for track in Track.objects.all():
save_artist_credit(track, ArtistCredit)
for album in Album.objects.all():
save_artist_credit(album, ArtistCredit)
class Migration(migrations.Migration):
dependencies = [
("music", "0058_upload_quality"),
]
operations = [
migrations.CreateModel(
name="ArtistCredit",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"fid",
models.URLField(
db_index=True, max_length=500, null=True, unique=True
),
),
(
"mbid",
models.UUIDField(blank=True, db_index=True, null=True, unique=True),
),
(
"uuid",
models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
(
"creation_date",
models.DateTimeField(
db_index=True, default=django.utils.timezone.now
),
),
(
"body_text",
django.contrib.postgres.search.SearchVectorField(blank=True),
),
("credit", models.CharField(blank=True, max_length=500, null=True)),
("joinphrase", models.CharField(blank=True, max_length=250, null=True)),
("index", models.IntegerField(blank=True, null=True)),
(
"artist",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="artist_credit",
to="music.artist",
),
),
(
"from_activity",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="federation.activity",
),
),
],
options={
"ordering": ["index", "credit"],
},
),
migrations.AddField(
model_name="album",
name="artist_credit",
field=models.ManyToManyField(
related_name="albums",
to="music.artistcredit",
),
),
migrations.AddField(
model_name="track",
name="artist_credit",
field=models.ManyToManyField(
related_name="tracks",
to="music.artistcredit",
),
),
migrations.RunPython(set_all_artists_credit, skip),
migrations.RemoveField(
model_name="album",
name="artist",
),
migrations.RemoveField(
model_name="track",
name="artist",
),
]

View File

@ -27,7 +27,7 @@ from django.utils import timezone
from funkwhale_api import musicbrainz from funkwhale_api import musicbrainz
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models from funkwhale_api.common import models as common_models
from funkwhale_api.common import session from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
@ -112,7 +112,6 @@ class APIModelMixin(models.Model):
def get_federation_id(self): def get_federation_id(self):
if self.fid: if self.fid:
return self.fid return self.fid
return federation_utils.full_url( return federation_utils.full_url(
reverse( reverse(
f"federation:music:{self.federation_namespace}-detail", f"federation:music:{self.federation_namespace}-detail",
@ -171,12 +170,12 @@ class License(models.Model):
class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet): class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def with_albums_count(self): def with_albums_count(self):
return self.annotate(_albums_count=models.Count("albums")) return self.annotate(_albums_count=models.Count("artist_credit__albums"))
def with_albums(self): def with_albums(self):
return self.prefetch_related( return self.prefetch_related(
models.Prefetch( models.Prefetch(
"albums", "artist_credit__albums",
queryset=Album.objects.with_tracks_count().select_related( queryset=Album.objects.with_tracks_count().select_related(
"attachment_cover", "attributed_to" "attachment_cover", "attributed_to"
), ),
@ -186,7 +185,7 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Upload.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(track__artist=models.OuterRef("id")) .filter(track__artist_credit__artist=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
@ -195,7 +194,9 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def playable_by(self, actor, include=True): def playable_by(self, actor, include=True):
tracks = Track.objects.playable_by(actor) tracks = Track.objects.playable_by(actor)
matches = self.filter(pk__in=tracks.values("artist_id")).values_list("pk") matches = self.filter(
pk__in=tracks.values("artist_credit__artist")
).values_list("pk")
if include: if include:
return self.filter(pk__in=matches) return self.filter(pk__in=matches)
else: else:
@ -272,9 +273,25 @@ class Artist(APIModelMixin):
return None return None
def import_artist(v): def import_artist_credit(v):
a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0] artists_credits = []
return a for i, ac in enumerate(v):
artist, create = Artist.get_or_create_from_api(mbid=ac["artist"]["id"])
if "joinphrase" in ac["artist"]:
joinphrase = ac["artist"]["joinphrase"]
elif i < len(v):
joinphrase = preferences.get("music__default_join_phrase")
else:
joinphrase = ""
artist_credit, created = ArtistCredit.objects.get_or_create(
artist=artist,
credit=ac["artist"]["name"],
index=i,
joinphrase=joinphrase,
)
artists_credits.append(artist_credit)
return artists_credits
def parse_date(v): def parse_date(v):
@ -290,6 +307,39 @@ def import_tracks(instance, cleaned_data, raw_data):
importers.load(Track, track_cleaned_data, track_data, Track.import_hooks) importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
class ArtistCreditQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def albums(self):
albums_ids = self.prefetch_related("albums").values_list("albums")
return Album.objects.filter(id__in=albums_ids)
class ArtistCredit(APIModelMixin):
artist = models.ForeignKey(
Artist, related_name="artist_credit", on_delete=models.CASCADE
)
credit = models.CharField(
null=True,
blank=True,
max_length=500,
)
joinphrase = models.CharField(
null=True,
blank=True,
max_length=250,
)
index = models.IntegerField(
null=True,
blank=True,
)
federation_namespace = "artistcredit"
objects = ArtistCreditQuerySet.as_manager()
class Meta:
ordering = ["index", "credit"]
class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet): class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def with_tracks_count(self): def with_tracks_count(self):
return self.annotate(_tracks_count=models.Count("tracks")) return self.annotate(_tracks_count=models.Count("tracks"))
@ -297,7 +347,7 @@ class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Upload.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(track__album=models.OuterRef("id")) .filter(track__artist_credit__albums=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
@ -329,7 +379,7 @@ class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
class Album(APIModelMixin): class Album(APIModelMixin):
title = models.TextField() title = models.TextField()
artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE) artist_credit = models.ManyToManyField(ArtistCredit, related_name="albums")
release_date = models.DateField(null=True, blank=True, db_index=True) release_date = models.DateField(null=True, blank=True, db_index=True)
release_group_id = models.UUIDField(null=True, blank=True) release_group_id = models.UUIDField(null=True, blank=True)
attachment_cover = models.ForeignKey( attachment_cover = models.ForeignKey(
@ -380,9 +430,9 @@ class Album(APIModelMixin):
"title": {"musicbrainz_field_name": "title"}, "title": {"musicbrainz_field_name": "title"},
"release_date": {"musicbrainz_field_name": "date", "converter": parse_date}, "release_date": {"musicbrainz_field_name": "date", "converter": parse_date},
"type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()}, "type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
"artist": { "artist_credit": {
"musicbrainz_field_name": "artist-credit", "musicbrainz_field_name": "artist-credit",
"converter": import_artist, "converter": import_artist_credit,
}, },
} }
objects = AlbumQuerySet.as_manager() objects = AlbumQuerySet.as_manager()
@ -405,6 +455,13 @@ class Album(APIModelMixin):
kwargs.update({"title": title}) kwargs.update({"title": title})
return cls.objects.get_or_create(title__iexact=title, defaults=kwargs) return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
@property
def get_artist_credit_string(self):
return utils.get_artist_credit_string(self)
def get_artists_list(self):
return [ac.artist for ac in self.artist_credit.all()]
def import_tags(instance, cleaned_data, raw_data): def import_tags(instance, cleaned_data, raw_data):
MINIMUM_COUNT = 2 MINIMUM_COUNT = 2
@ -428,11 +485,11 @@ def import_album(v):
class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet): class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def for_nested_serialization(self): def for_nested_serialization(self):
return self.prefetch_related( return self.prefetch_related(
"artist", "artist_credit",
Prefetch( Prefetch(
"album", "album",
queryset=Album.objects.select_related( queryset=Album.objects.prefetch_related(
"artist", "attachment_cover" "artist_credit", "attachment_cover"
).annotate(_prefetched_tracks_count=Count("tracks")), ).annotate(_prefetched_tracks_count=Count("tracks")),
), ),
) )
@ -485,7 +542,7 @@ def get_artist(release_list):
class Track(APIModelMixin): class Track(APIModelMixin):
mbid = models.UUIDField(db_index=True, null=True, blank=True) mbid = models.UUIDField(db_index=True, null=True, blank=True)
title = models.TextField() title = models.TextField()
artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE) artist_credit = models.ManyToManyField(ArtistCredit, related_name="tracks")
disc_number = models.PositiveIntegerField(null=True, blank=True) disc_number = models.PositiveIntegerField(null=True, blank=True)
position = models.PositiveIntegerField(null=True, blank=True) position = models.PositiveIntegerField(null=True, blank=True)
album = models.ForeignKey( album = models.ForeignKey(
@ -527,11 +584,9 @@ class Track(APIModelMixin):
musicbrainz_mapping = { musicbrainz_mapping = {
"mbid": {"musicbrainz_field_name": "id"}, "mbid": {"musicbrainz_field_name": "id"},
"title": {"musicbrainz_field_name": "title"}, "title": {"musicbrainz_field_name": "title"},
"artist": { "artist_credit": {
"musicbrainz_field_name": "artist-credit", "musicbrainz_field_name": "artist-credit",
"converter": lambda v: Artist.get_or_create_from_api( "converter": import_artist_credit,
mbid=v[0]["artist"]["id"]
)[0],
}, },
"album": {"musicbrainz_field_name": "release-list", "converter": import_album}, "album": {"musicbrainz_field_name": "release-list", "converter": import_album},
} }
@ -559,19 +614,21 @@ class Track(APIModelMixin):
def get_moderation_url(self): def get_moderation_url(self):
return f"/manage/library/tracks/{self.pk}" return f"/manage/library/tracks/{self.pk}"
def save(self, **kwargs): @property
try: def get_artist_credit_string(self):
self.artist return utils.get_artist_credit_string(self)
except Artist.DoesNotExist:
self.artist = self.album.artist def get_artists_list(self):
super().save(**kwargs) return [ac.artist for ac in self.artist_credit.all()]
@property @property
def full_name(self): def full_name(self):
try: try:
return f"{self.artist.name} - {self.album.title} - {self.title}" return (
f"{self.get_artist_credit_string} - {self.album.title} - {self.title}"
)
except AttributeError: except AttributeError:
return f"{self.artist.name} - {self.title}" return f"{self.get_artist_credit_string} - {self.title}"
@property @property
def cover(self): def cover(self):
@ -587,6 +644,9 @@ class Track(APIModelMixin):
kwargs.update({"title": title}) kwargs.update({"title": title})
return cls.objects.get_or_create(title__iexact=title, defaults=kwargs) return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
# not used anymore, allow increase of performance when importing tracks using mbids.
# In its actual state it will not work since it assume track_data["recording"]["artist-credit"] can
# contain a joinphrase but it's not he case. Needs to be updated.
@classmethod @classmethod
def get_or_create_from_release(cls, release_mbid, mbid): def get_or_create_from_release(cls, release_mbid, mbid):
release_mbid = str(release_mbid) release_mbid = str(release_mbid)
@ -609,33 +669,42 @@ class Track(APIModelMixin):
if not track_data: if not track_data:
raise ValueError("No track found matching this ID") raise ValueError("No track found matching this ID")
track_artist_mbid = None artists_credits = []
for ac in track_data["recording"]["artist-credit"]: for i, ac in enumerate(track_data["recording"]["artist-credit"]):
try: try:
ac_mbid = ac["artist"]["id"] ac_mbid = ac["artist"]["id"]
except TypeError: except TypeError:
# it's probably a string, like "feat." # it's probably a string, like "feat.".
continue continue
if ac_mbid == str(album.artist.mbid): track_artist = Artist.get_or_create_from_api(ac_mbid)[0]
continue
track_artist_mbid = ac_mbid if "joinphrase" not in ac:
break joinphrase = ""
track_artist_mbid = track_artist_mbid or album.artist.mbid else:
if track_artist_mbid == str(album.artist.mbid): joinphrase = ac["joinphrase"]
track_artist = album.artist
else: artist_credit, create = ArtistCredit.objects.get_or_create(
track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0] artist=track_artist,
return cls.objects.update_or_create( credit=ac["artist"]["name"],
joinphrase=joinphrase,
index=i,
)
artists_credits.append(artist_credit)
if album.artist_credit.all() != artist_credit:
album.artist_credit.set(artists_credits)
track = cls.objects.update_or_create(
mbid=mbid, mbid=mbid,
defaults={ defaults={
"position": int(track["position"]), "position": int(track["position"]),
"title": track["recording"]["title"], "title": track["recording"]["title"],
"album": album, "album": album,
"artist": track_artist,
}, },
) )
track[0].artist_credit.set(artists_credits)
return track
@property @property
def listen_url(self) -> str: def listen_url(self) -> str:
@ -809,7 +878,7 @@ class Upload(models.Model):
title_parts.append(self.track.title) title_parts.append(self.track.title)
if self.track.album: if self.track.album:
title_parts.append(self.track.album.title) title_parts.append(self.track.album.title)
title_parts.append(self.track.artist.name) title_parts.append(self.track.get_artist_credit_string)
title = " - ".join(title_parts) title = " - ".join(title_parts)
filename = f"{title}.{extension}" filename = f"{title}.{extension}"
@ -1032,9 +1101,21 @@ class Upload(models.Model):
if self.track.album if self.track.album
else tags_models.TaggedItem.objects.none() else tags_models.TaggedItem.objects.none()
) )
artist_tags = self.track.artist.tagged_items.all() artist_tags = [
ac.artist.tagged_items.all() for ac in self.track.artist_credit.all()
]
non_empty_artist_tags = []
for qs in artist_tags:
if qs.exists():
non_empty_artist_tags.append(qs)
items = (track_tags | album_tags | artist_tags).order_by("tag__name") if non_empty_artist_tags:
final_qs = (track_tags | album_tags).union(*non_empty_artist_tags)
else:
final_qs = track_tags | album_tags
# this is needed to avoid *** RuntimeError: generator raised StopIteration
final_list = [obj for obj in final_qs]
items = sorted(final_list, key=lambda x: x.tag.name if x.tag else "")
return items return items

View File

@ -126,7 +126,11 @@ class TrackMutationSerializer(CoverMutation, TagMutation, DescriptionMutation):
return serialized_relations return serialized_relations
def post_apply(self, obj, validated_data): def post_apply(self, obj, validated_data):
channel = obj.artist.get_channel() channel = (
obj.artist_credit.all()[0].artist.get_channel()
if len(obj.artist_credit.all()) == 1
else None
)
if channel: if channel:
upload = channel.library.uploads.filter(track=obj).first() upload = channel.library.uploads.filter(track=obj).first()
if upload: if upload:

View File

@ -81,12 +81,12 @@ class ArtistAlbumSerializer(serializers.Serializer):
fid = serializers.URLField() fid = serializers.URLField()
mbid = serializers.UUIDField() mbid = serializers.UUIDField()
title = serializers.CharField() title = serializers.CharField()
artist = serializers.SerializerMethodField() artist_credit = serializers.SerializerMethodField()
release_date = serializers.DateField() release_date = serializers.DateField()
creation_date = serializers.DateTimeField() creation_date = serializers.DateTimeField()
def get_artist(self, o) -> int: def get_artist_credit(self, o) -> int:
return o.artist_id return [ac.id for ac in o.artist_credit.all()]
def get_tracks_count(self, o) -> int: def get_tracks_count(self, o) -> int:
return len(o.tracks.all()) return len(o.tracks.all())
@ -113,7 +113,7 @@ class ArtistWithAlbumsInlineChannelSerializer(serializers.Serializer):
class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer): class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer):
albums = ArtistAlbumSerializer(many=True) albums = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
attributed_to = APIActorSerializer(allow_null=True) attributed_to = APIActorSerializer(allow_null=True)
channel = ArtistWithAlbumsInlineChannelSerializer(allow_null=True) channel = ArtistWithAlbumsInlineChannelSerializer(allow_null=True)
@ -127,14 +127,17 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize
is_local = serializers.BooleanField() is_local = serializers.BooleanField()
cover = CoverField(allow_null=True) cover = CoverField(allow_null=True)
def get_albums(self, artist):
albums = artist.artist_credit.albums()
return ArtistAlbumSerializer(albums, many=True).data
@extend_schema_field({"type": "array", "items": {"type": "string"}}) @extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj): def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", []) tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items] return [ti.tag.name for ti in tagged_items]
def get_tracks_count(self, o) -> int: def get_tracks_count(self, o) -> int:
tracks = getattr(o, "_prefetched_tracks", None) return getattr(o, "_tracks_count", 0)
return len(tracks) if tracks else 0
class SimpleArtistSerializer(serializers.ModelSerializer): class SimpleArtistSerializer(serializers.ModelSerializer):
@ -156,11 +159,20 @@ class SimpleArtistSerializer(serializers.ModelSerializer):
"description", "description",
"attachment_cover", "attachment_cover",
"channel", "channel",
"attributed_to",
) )
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer): class ArtistCreditSerializer(serializers.ModelSerializer):
artist = SimpleArtistSerializer() artist = SimpleArtistSerializer()
class Meta:
model = models.ArtistCredit
fields = ["artist", "credit", "joinphrase", "index"]
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist_credit = ArtistCreditSerializer(many=True)
cover = CoverField(allow_null=True) cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField() is_playable = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
@ -203,7 +215,7 @@ class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
class TrackAlbumSerializer(serializers.ModelSerializer): class TrackAlbumSerializer(serializers.ModelSerializer):
artist = SimpleArtistSerializer() artist_credit = ArtistCreditSerializer(many=True)
cover = CoverField(allow_null=True) cover = CoverField(allow_null=True)
tracks_count = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField()
@ -217,7 +229,7 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
"fid", "fid",
"mbid", "mbid",
"title", "title",
"artist", "artist_credit",
"release_date", "release_date",
"cover", "cover",
"creation_date", "creation_date",
@ -257,7 +269,7 @@ def sort_uploads_for_listen(uploads):
class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist = SimpleArtistSerializer() artist_credit = ArtistCreditSerializer(many=True)
album = TrackAlbumSerializer(read_only=True) album = TrackAlbumSerializer(read_only=True)
uploads = serializers.SerializerMethodField() uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField()
@ -400,9 +412,9 @@ class UploadSerializer(serializers.ModelSerializer):
def filter_album(qs, context): def filter_album(qs, context):
if "channel" in context: if "channel" in context:
return qs.filter(artist__channel=context["channel"]) return qs.filter(artist_credit__artist__channel=context["channel"])
if "actor" in context: if "actor" in context:
return qs.filter(artist__attributed_to=context["actor"]) return qs.filter(artist_credit__artist__attributed_to=context["actor"])
return qs.none() return qs.none()
@ -567,12 +579,12 @@ class SimpleAlbumSerializer(serializers.ModelSerializer):
class TrackActivitySerializer(activity_serializers.ModelSerializer): class TrackActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
name = serializers.CharField(source="title") name = serializers.CharField(source="title")
artist = serializers.CharField(source="artist.name") artist_credit = serializers.CharField(source="get_artist_credit_string")
album = serializers.SerializerMethodField() album = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Track model = models.Track
fields = ["id", "local_id", "name", "type", "artist", "album"] fields = ["id", "local_id", "name", "type", "artist_credit", "album"]
def get_type(self, obj): def get_type(self, obj):
return "Audio" return "Audio"
@ -612,9 +624,9 @@ class OembedSerializer(serializers.Serializer):
embed_id = None embed_id = None
embed_type = None embed_type = None
if match.url_name == "library_track": if match.url_name == "library_track":
qs = models.Track.objects.select_related("artist", "album__artist").filter( qs = models.Track.objects.prefetch_related(
pk=int(match.kwargs["pk"]) "artist_credit", "album__artist_credit"
) ).filter(pk=int(match.kwargs["pk"]))
try: try:
track = qs.get() track = qs.get()
except models.Track.DoesNotExist: except models.Track.DoesNotExist:
@ -623,7 +635,7 @@ class OembedSerializer(serializers.Serializer):
) )
embed_type = "track" embed_type = "track"
embed_id = track.pk embed_id = track.pk
data["title"] = f"{track.title} by {track.artist.name}" data["title"] = f"{track.title} by {track.get_artist_credit_string}"
if track.attachment_cover: if track.attachment_cover:
data[ data[
"thumbnail_url" "thumbnail_url"
@ -637,15 +649,17 @@ class OembedSerializer(serializers.Serializer):
data["thumbnail_width"] = 200 data["thumbnail_width"] = 200
data["thumbnail_height"] = 200 data["thumbnail_height"] = 200
data["description"] = track.full_name data["description"] = track.full_name
data["author_name"] = track.artist.name data["author_name"] = track.get_artist_credit_string
data["height"] = 150 data["height"] = 150
# here we take the first artist since oembed standard do not allow a list of url
data["author_url"] = federation_utils.full_url( data["author_url"] = federation_utils.full_url(
common_utils.spa_reverse( common_utils.spa_reverse(
"library_artist", kwargs={"pk": track.artist.pk} "library_artist",
kwargs={"pk": track.artist_credit.all()[0].artist.pk},
) )
) )
elif match.url_name == "library_album": elif match.url_name == "library_album":
qs = models.Album.objects.select_related("artist").filter( qs = models.Album.objects.prefetch_related("artist_credit").filter(
pk=int(match.kwargs["pk"]) pk=int(match.kwargs["pk"])
) )
try: try:
@ -662,15 +676,17 @@ class OembedSerializer(serializers.Serializer):
] = album.attachment_cover.download_url_medium_square_crop ] = album.attachment_cover.download_url_medium_square_crop
data["thumbnail_width"] = 200 data["thumbnail_width"] = 200
data["thumbnail_height"] = 200 data["thumbnail_height"] = 200
data["title"] = f"{album.title} by {album.artist.name}" data["title"] = f"{album.title} by {album.get_artist_credit_string}"
data["description"] = f"{album.title} by {album.artist.name}" data["description"] = f"{album.title} by {album.get_artist_credit_string}"
data["author_name"] = album.artist.name data["author_name"] = album.get_artist_credit_string
data["height"] = 400 data["height"] = 400
data["author_url"] = federation_utils.full_url( data["author_url"] = federation_utils.full_url(
common_utils.spa_reverse( common_utils.spa_reverse(
"library_artist", kwargs={"pk": album.artist.pk} "library_artist",
kwargs={"pk": album.artist_credit.all()[0].artist.pk},
) )
) )
elif match.url_name == "library_artist": elif match.url_name == "library_artist":
qs = models.Artist.objects.filter(pk=int(match.kwargs["pk"])) qs = models.Artist.objects.filter(pk=int(match.kwargs["pk"]))
try: try:
@ -681,7 +697,17 @@ class OembedSerializer(serializers.Serializer):
) )
embed_type = "artist" embed_type = "artist"
embed_id = artist.pk embed_id = artist.pk
album = artist.albums.exclude(attachment_cover=None).order_by("-id").first() album_ids = (
artist.artist_credit.all()
.prefetch_related("albums")
.values_list("albums", flat=True)
)
album = (
models.Album.objects.exclude(attachment_cover=None)
.filter(pk__in=album_ids)
.order_by("-id")
.first()
)
if album and album.attachment_cover: if album and album.attachment_cover:
data[ data[
@ -791,32 +817,34 @@ class AlbumCreateSerializer(serializers.Serializer):
release_date = serializers.DateField(required=False, allow_null=True) release_date = serializers.DateField(required=False, allow_null=True)
tags = tags_serializers.TagsListField(required=False) tags = tags_serializers.TagsListField(required=False)
description = common_serializers.ContentSerializer(allow_null=True, required=False) description = common_serializers.ContentSerializer(allow_null=True, required=False)
# only used in album channel creation, so this is not a list
artist = common_serializers.RelatedField( artist_credit = common_serializers.RelatedField(
"id", "id",
queryset=models.Artist.objects.exclude(channel__isnull=True), queryset=models.ArtistCredit.objects.exclude(artist__channel__isnull=True),
required=True, required=True,
serializer=None, serializer=None,
filters=lambda context: {"attributed_to": context["user"].actor}, many=True,
filters=lambda context: {"artist__attributed_to": context["user"].actor},
) )
def validate(self, validated_data): def validate(self, validated_data):
duplicates = validated_data["artist"].albums.filter( duplicates = models.Album.objects.none()
title__iexact=validated_data["title"] for ac in validated_data["artist_credit"]:
) duplicates = duplicates | ac.albums.filter(
title__iexact=validated_data["title"]
)
if duplicates.exists(): if duplicates.exists():
raise serializers.ValidationError("An album with this title already exist") raise serializers.ValidationError("An album with this title already exist")
return super().validate(validated_data) return super().validate(validated_data)
def to_representation(self, obj): def to_representation(self, obj):
obj.artist.attachment_cover
return AlbumSerializer(obj, context=self.context).data return AlbumSerializer(obj, context=self.context).data
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
instance = models.Album.objects.create( instance = models.Album.objects.create(
attributed_to=self.context["user"].actor, attributed_to=self.context["user"].actor,
artist=validated_data["artist"],
release_date=validated_data.get("release_date"), release_date=validated_data.get("release_date"),
title=validated_data["title"], title=validated_data["title"],
attachment_cover=validated_data.get("cover"), attachment_cover=validated_data.get("cover"),
@ -825,7 +853,8 @@ class AlbumCreateSerializer(serializers.Serializer):
instance, "description", validated_data.get("description") instance, "description", validated_data.get("description")
) )
tag_models.set_tags(instance, *(validated_data.get("tags", []) or [])) tag_models.set_tags(instance, *(validated_data.get("tags", []) or []))
instance.artist.get_channel()
instance.artist_credit.set(validated_data["artist_credit"])
return instance return instance

View File

@ -24,7 +24,9 @@ def get_twitter_card_metas(type, id):
def library_track(request, pk, redirect_to_ap): def library_track(request, pk, redirect_to_ap):
queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist") queryset = models.Track.objects.filter(pk=pk).prefetch_related(
"album", "artist_credit__artist"
)
try: try:
obj = queryset.get() obj = queryset.get()
except models.Track.DoesNotExist: except models.Track.DoesNotExist:
@ -47,15 +49,19 @@ def library_track(request, pk, redirect_to_ap):
{"tag": "meta", "property": "og:type", "content": "music.song"}, {"tag": "meta", "property": "og:type", "content": "music.song"},
{"tag": "meta", "property": "music:album:disc", "content": obj.disc_number}, {"tag": "meta", "property": "music:album:disc", "content": obj.disc_number},
{"tag": "meta", "property": "music:album:track", "content": obj.position}, {"tag": "meta", "property": "music:album:track", "content": obj.position},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
),
},
] ]
# following https://ogp.me/#array
for ac in obj.artist_credit.all():
metas.append(
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": ac.artist.pk}),
),
}
)
if obj.album: if obj.album:
metas.append( metas.append(
@ -119,7 +125,7 @@ def library_track(request, pk, redirect_to_ap):
def library_album(request, pk, redirect_to_ap): def library_album(request, pk, redirect_to_ap):
queryset = models.Album.objects.filter(pk=pk).select_related("artist") queryset = models.Album.objects.filter(pk=pk).prefetch_related("artist_credit")
try: try:
obj = queryset.get() obj = queryset.get()
except models.Album.DoesNotExist: except models.Album.DoesNotExist:
@ -136,16 +142,20 @@ def library_album(request, pk, redirect_to_ap):
{"tag": "meta", "property": "og:url", "content": album_url}, {"tag": "meta", "property": "og:url", "content": album_url},
{"tag": "meta", "property": "og:title", "content": obj.title}, {"tag": "meta", "property": "og:title", "content": obj.title},
{"tag": "meta", "property": "og:type", "content": "music.album"}, {"tag": "meta", "property": "og:type", "content": "music.album"},
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
),
},
] ]
# following https://ogp.me/#array
for ac in obj.artist_credit.all():
metas.append(
{
"tag": "meta",
"property": "music:musician",
"content": utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": ac.artist.pk}),
),
}
)
if obj.release_date: if obj.release_date:
metas.append( metas.append(
{ {
@ -206,7 +216,10 @@ def library_artist(request, pk, redirect_to_ap):
) )
# we use latest album's cover as artist image # we use latest album's cover as artist image
latest_album = ( latest_album = (
obj.albums.exclude(attachment_cover=None).order_by("release_date").last() obj.artist_credit.albums()
.exclude(attachment_cover=None)
.order_by("release_date")
.last()
) )
metas = [ metas = [
{"tag": "meta", "property": "og:url", "content": artist_url}, {"tag": "meta", "property": "og:url", "content": artist_url},
@ -234,7 +247,10 @@ def library_artist(request, pk, redirect_to_ap):
) )
if ( if (
models.Upload.objects.filter(Q(track__artist=obj) | Q(track__album__artist=obj)) models.Upload.objects.filter(
Q(track__artist_credit__artist=obj)
| Q(track__album__artist_credit__artist=obj)
)
.playable_by(None) .playable_by(None)
.exists() .exists()
): ):

View File

@ -2,6 +2,7 @@ import collections
import datetime import datetime
import logging import logging
import os import os
import re
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@ -9,7 +10,7 @@ from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from musicbrainzngs import ResponseError from musicbrainzngs import NetworkError, ResponseError
from requests.exceptions import RequestException from requests.exceptions import RequestException
from funkwhale_api import musicbrainz from funkwhale_api import musicbrainz
@ -226,7 +227,7 @@ def process_upload(upload, update_denormalization=True):
forced_values["artist"] = upload.library.channel.artist forced_values["artist"] = upload.library.channel.artist
old_status = upload.import_status old_status = upload.import_status
additional_data = {"upload_source": upload.source} upload_source = {"upload_source": upload.source}
if use_file_metadata: if use_file_metadata:
audio_file = upload.get_audio_file() audio_file = upload.get_audio_file()
@ -256,11 +257,11 @@ def process_upload(upload, update_denormalization=True):
) )
final_metadata = collections.ChainMap( final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config upload_source, serializer.validated_data, internal_config
) )
else: else:
final_metadata = collections.ChainMap( final_metadata = collections.ChainMap(
additional_data, upload_source,
forced_values, forced_values,
internal_config, internal_config,
) )
@ -270,8 +271,8 @@ def process_upload(upload, update_denormalization=True):
) )
except UploadImportError as e: except UploadImportError as e:
return fail_import(upload, e.code) return fail_import(upload, e.code)
except Exception: except Exception as e:
fail_import(upload, "unknown_error") fail_import(upload, "unknown_error", e)
raise raise
broadcast = getter( broadcast = getter(
@ -395,38 +396,53 @@ def federation_audio_track_to_metadata(payload, references):
"cover_data": get_cover(payload["album"], "image"), "cover_data": get_cover(payload["album"], "image"),
"release_date": payload["album"].get("released"), "release_date": payload["album"].get("released"),
"tags": [t["name"] for t in payload["album"].get("tags", []) or []], "tags": [t["name"] for t in payload["album"].get("tags", []) or []],
"artists": [ "artist_credit": [
{ {
"fid": a["id"], "artist": {
"name": a["name"], "fid": a["artist"]["id"],
"fdate": a["published"], "name": a["artist"]["name"],
"cover_data": get_cover(a, "image"), "fdate": a["artist"]["published"],
"description": a.get("description"), "cover_data": get_cover(a["artist"], "image"),
"attributed_to": references.get(a.get("attributedTo")), "description": a["artist"].get("description"),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "attributed_to": references.get(
"tags": [t["name"] for t in a.get("tags", []) or []], a["artist"].get("attributedTo")
),
"mbid": str(a["artist"]["musicbrainzId"])
if a["artist"].get("musicbrainzId")
else None,
"tags": [t["name"] for t in a["artist"].get("tags", []) or []],
},
"joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
"credit": a["credit"],
} }
for a in payload["album"]["artists"] for a in payload["album"]["artist_credit"]
], ],
}, },
"artists": [ "artist_credit": [
{ {
"fid": a["id"], "artist": {
"name": a["name"], "fid": a["artist"]["id"],
"fdate": a["published"], "name": a["artist"]["name"],
"description": a.get("description"), "fdate": a["artist"]["published"],
"attributed_to": references.get(a.get("attributedTo")), "description": a["artist"].get("description"),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "attributed_to": references.get(a["artist"].get("attributedTo")),
"tags": [t["name"] for t in a.get("tags", []) or []], "mbid": str(a["artist"]["musicbrainzId"])
"cover_data": get_cover(a, "image"), if a["artist"].get("musicbrainzId")
else None,
"tags": [t["name"] for t in a["artist"].get("tags", []) or []],
"cover_data": get_cover(a["artist"], "image"),
},
"joinphrase": (a["joinphrase"] if "joinphrase" in a else ""),
"credit": a["credit"],
} }
for a in payload["artists"] for a in payload["artist_credit"]
], ],
# federation # federation
"fid": payload["id"], "fid": payload["id"],
"fdate": payload["published"], "fdate": payload["published"],
"tags": [t["name"] for t in payload.get("tags", []) or []], "tags": [t["name"] for t in payload.get("tags", []) or []],
} }
return new_data return new_data
@ -434,6 +450,7 @@ def get_owned_duplicates(upload, track):
""" """
Ensure we skip duplicate tracks to avoid wasting user/instance storage Ensure we skip duplicate tracks to avoid wasting user/instance storage
""" """
owned_libraries = upload.library.actor.libraries.all() owned_libraries = upload.library.actor.libraries.all()
return ( return (
models.Upload.objects.filter( models.Upload.objects.filter(
@ -491,9 +508,11 @@ def sort_candidates(candidates, important_fields):
@transaction.atomic @transaction.atomic
def get_track_from_import_metadata( def get_track_from_import_metadata(
data, update_cover=False, attributed_to=None, **forced_values data, update_cover=False, attributed_to=None, query_mb=True, **forced_values
): ):
track = _get_track(data, attributed_to=attributed_to, **forced_values) track = _get_track(
data, attributed_to=attributed_to, query_mb=query_mb, **forced_values
)
if update_cover and track and not track.album.attachment_cover: if update_cover and track and not track.album.attachment_cover:
populate_album_cover(track.album, source=data.get("upload_source")) populate_album_cover(track.album, source=data.get("upload_source"))
return track return track
@ -505,7 +524,7 @@ def truncate(v, length):
return v[:length] return v[:length]
def _get_track(data, attributed_to=None, **forced_values): def _get_track(data, attributed_to=None, query_mb=True, **forced_values):
sync_mb_tag = preferences.get("music__sync_musicbrainz_tags") sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
track_uuid = getter(data, "funkwhale", "track", "uuid") track_uuid = getter(data, "funkwhale", "track", "uuid")
@ -548,64 +567,64 @@ def _get_track(data, attributed_to=None, **forced_values):
except IndexError: except IndexError:
pass pass
# get / create artist and album artist # get / create artist, artist_credit and album artist, album artist_credit
artists = getter(data, "artists", default=[]) album_artists_credits = None
artist_credit_data = getter(data, "artist_credit", default=[])
if "artist" in forced_values: if "artist" in forced_values:
artist = forced_values["artist"] artist = forced_values["artist"]
else: query = Q(artist=artist)
artist_data = artists[0] defaults = {
artist = get_artist( "artist": artist,
artist_data, attributed_to=attributed_to, from_activity_id=from_activity_id "joinphrase": "",
"credit": artist.name,
}
track_artist_credit, created = get_best_candidate_or_create(
models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
) )
artist_name = artist.name track_artists_credits = [track_artist_credit]
else:
mbid = query_mb and (data.get("musicbrainz_id", None) or data.get("mbid", None))
try:
track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
"recording",
mbid,
attributed_to=attributed_to,
from_activity_id=from_activity_id,
)
except (NoMbid, ResponseError, NetworkError):
track_artists_credits = (
get_or_create_artists_credits_from_artist_credit_metadata(
artist_credit_data,
attributed_to=attributed_to,
from_activity_id=from_activity_id,
)
)
if "album" in forced_values: if "album" in forced_values:
album = forced_values["album"] album = forced_values["album"]
album_artists_credits = track_artists_credits
else: else:
if "artist" in forced_values: if album_artists_credits:
album_artist = forced_values["artist"] pass
else: mbid = query_mb and (data.get("musicbrainz_albumid", None) or album_mbid)
album_artists = getter(data, "album", "artists", default=artists) or artists try:
album_artist_data = album_artists[0] album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
album_artist_name = album_artist_data.get("name") "release",
if album_artist_name == artist_name: mbid,
album_artist = artist attributed_to=attributed_to,
else: from_activity_id=from_activity_id,
query = Q(name__iexact=album_artist_name) )
album_artist_mbid = album_artist_data.get("mbid", None) except (NoMbid, ResponseError, NetworkError):
album_artist_fid = album_artist_data.get("fid", None) if album_artists := getter(data, "album", "artist_credit", default=None):
if album_artist_mbid: album_artists_credits = (
query |= Q(mbid=album_artist_mbid) get_or_create_artists_credits_from_artist_credit_metadata(
if album_artist_fid: album_artists,
query |= Q(fid=album_artist_fid) attributed_to=attributed_to,
defaults = { from_activity_id=from_activity_id,
"name": album_artist_name, )
"mbid": album_artist_mbid,
"fid": album_artist_fid,
"from_activity_id": from_activity_id,
"attributed_to": album_artist_data.get(
"attributed_to", attributed_to
),
}
if album_artist_data.get("fdate"):
defaults["creation_date"] = album_artist_data.get("fdate")
album_artist, created = get_best_candidate_or_create(
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
) )
if created: else:
tags_models.add_tags( album_artists_credits = track_artists_credits
album_artist, *album_artist_data.get("tags", [])
)
common_utils.attach_content(
album_artist,
"description",
album_artist_data.get("description"),
)
common_utils.attach_file(
album_artist,
"attachment_cover",
album_artist_data.get("cover_data"),
)
# get / create album # get / create album
if "album" in data: if "album" in data:
@ -616,13 +635,15 @@ def _get_track(data, attributed_to=None, **forced_values):
if album_mbid: if album_mbid:
query = Q(mbid=album_mbid) query = Q(mbid=album_mbid)
else: else:
query = Q(title__iexact=album_title, artist=album_artist) query = Q(
title__iexact=album_title, artist_credit__in=album_artists_credits
)
if album_fid: if album_fid:
query |= Q(fid=album_fid) query |= Q(fid=album_fid)
defaults = { defaults = {
"title": album_title, "title": album_title,
"artist": album_artist,
"mbid": album_mbid, "mbid": album_mbid,
"release_date": album_data.get("release_date"), "release_date": album_data.get("release_date"),
"fid": album_fid, "fid": album_fid,
@ -635,6 +656,8 @@ def _get_track(data, attributed_to=None, **forced_values):
album, created = get_best_candidate_or_create( album, created = get_best_candidate_or_create(
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
) )
album.artist_credit.set(album_artists_credits)
if created: if created:
tags_models.add_tags(album, *album_data.get("tags", [])) tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content( common_utils.attach_content(
@ -682,7 +705,7 @@ def _get_track(data, attributed_to=None, **forced_values):
query = Q( query = Q(
title__iexact=track_title, title__iexact=track_title,
artist=artist, artist_credit__in=track_artists_credits,
album=album, album=album,
position=position, position=position,
disc_number=disc_number, disc_number=disc_number,
@ -695,17 +718,10 @@ def _get_track(data, attributed_to=None, **forced_values):
if track_fid: if track_fid:
query |= Q(fid=track_fid) query |= Q(fid=track_fid)
if album and len(artists) > 1:
# we use the second artist to preserve featuring information
artist = artist = get_artist(
artists[1], attributed_to=attributed_to, from_activity_id=from_activity_id
)
defaults = { defaults = {
"title": track_title, "title": track_title,
"album": album, "album": album,
"mbid": track_mbid, "mbid": track_mbid,
"artist": artist,
"position": position, "position": position,
"disc_number": disc_number, "disc_number": disc_number,
"fid": track_fid, "fid": track_fid,
@ -732,27 +748,36 @@ def _get_track(data, attributed_to=None, **forced_values):
if sync_mb_tag and track_mbid: if sync_mb_tag and track_mbid:
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(track) tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(track)
track.artist_credit.set(track_artists_credits)
return track return track
def get_artist(artist_data, attributed_to, from_activity_id): def get_or_create_artist(artist_data, attributed_to, from_activity_id):
sync_mb_tag = preferences.get("music__sync_musicbrainz_tags") sync_mb_tag = preferences.get("music__sync_musicbrainz_tags")
artist_mbid = artist_data.get("mbid", None) mbid = artist_data.get("artist", {}).get("mbid", None)
artist_fid = artist_data.get("fid", None) fid = artist_data.get("artist", {}).get("fid", None)
artist_name = artist_data["name"] name = artist_data.get("artist", {}).get("name", artist_data["credit"])
creation_date = artist_data.get("artist", {}).get("fdate", timezone.now())
description = artist_data.get("artist", {}).get("description", None)
attributed_to = artist_data.get("artist", {}).get("attributed_to", attributed_to)
tags = artist_data.get("artist", {}).get("tags", [])
cover = artist_data.get("artist", {}).get("cover_data", None)
if artist_mbid: if mbid:
query = Q(mbid=artist_mbid) query = Q(mbid=mbid)
else: else:
query = Q(name__iexact=artist_name) query = Q(name__iexact=name)
if artist_fid:
query |= Q(fid=artist_fid) if fid:
query |= Q(fid=fid)
defaults = { defaults = {
"name": artist_name, "name": name,
"mbid": artist_mbid, "mbid": mbid,
"fid": artist_fid, "fid": fid,
"from_activity_id": from_activity_id, "from_activity_id": from_activity_id,
"attributed_to": artist_data.get("attributed_to", attributed_to), "attributed_to": attributed_to,
"creation_date": creation_date,
} }
if artist_data.get("fdate"): if artist_data.get("fdate"):
defaults["creation_date"] = artist_data.get("fdate") defaults["creation_date"] = artist_data.get("fdate")
@ -761,19 +786,162 @@ def get_artist(artist_data, attributed_to, from_activity_id):
models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
) )
if created: if created:
tags_models.add_tags(artist, *artist_data.get("tags", [])) tags_models.add_tags(artist, *tags)
common_utils.attach_content( common_utils.attach_content(artist, "description", description)
artist, "description", artist_data.get("description") common_utils.attach_file(artist, "attachment_cover", cover)
) if sync_mb_tag and mbid:
common_utils.attach_file(
artist, "attachment_cover", artist_data.get("cover_data")
)
if sync_mb_tag and artist_mbid:
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(artist) tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(artist)
return artist return artist
class NoMbid(Exception):
pass
def get_or_create_artists_credits_from_musicbrainz(
mb_obj_type, mbid, attributed_to, from_activity_id
):
if not mbid:
raise NoMbid
try:
if mb_obj_type == "release":
mb_obj = musicbrainz.api.releases.get(mbid, includes=["artists"])
elif mb_obj_type == "recording":
mb_obj = musicbrainz.api.recordings.get(mbid, includes=["artists"])
except (ResponseError, NetworkError) as e:
logger.warning(
f"Couldn't get Musicbrainz information for {mb_obj_type} with {mbid} mbid \
because of the following exception : {e}"
)
raise e
artists_credits = []
acs = mb_obj.get("recording", mb_obj)["artist-credit"]
for i, ac in enumerate(acs):
if isinstance(ac, str):
continue
artist_mbid = ac["artist"]["id"]
artist_name = ac["artist"]["name"]
credit = ac.get("name", artist_name)
joinphrase = ac["joinphrase"]
# artist creation
query = Q(mbid=artist_mbid)
defaults = {
"name": artist_name,
"mbid": artist_mbid,
"from_activity_id": from_activity_id,
"attributed_to": attributed_to,
}
artist, created = get_best_candidate_or_create(
models.Artist, query, defaults=defaults, sort_fields=["mbid"]
)
# we could import artist tag, description, cover here.
# artist_credit creation
defaults = {
"artist": artist,
"joinphrase": joinphrase,
"credit": credit,
"index": i,
}
query = (
Q(artist=artist.pk)
& Q(joinphrase=joinphrase)
& Q(credit=credit)
& Q(index=i)
)
artist_credit, created = get_best_candidate_or_create(
models.ArtistCredit, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
artists_credits.append(artist_credit)
return artists_credits
def parse_credits(artist_string, forced_joinphrase, forced_index, forced_artist=None):
"""
Return a list of parsed artist_credit information from a string like :
LoveDiversity featuring Hatingprisons
"""
if not artist_string:
return []
join_phrase = preferences.get("music__join_phrases")
join_phrase_regex = re.compile(rf"({join_phrase})", re.IGNORECASE)
split = re.split(join_phrase_regex, artist_string)
raw_artists_credits = tuple(zip(split[0::2], split[1::2]))
artists_credits_tuple = []
for index, raw_artist_credit in enumerate(raw_artists_credits):
credit = raw_artist_credit[0].strip()
if forced_joinphrase:
join_phrase = forced_joinphrase
else:
join_phrase = raw_artist_credit[1]
if join_phrase == "( " or join_phrase == ") ":
join_phrase = join_phrase.strip()
artists_credits_tuple.append(
(
credit,
join_phrase,
(index if not forced_index else forced_index),
forced_artist,
)
)
# impar split :
if len(split) % 2 != 0 and split[len(split) - 1] != "" and len(split) > 1:
artists_credits_tuple.append(
(
str(split[len(split) - 1]).rstrip(),
("" if not forced_joinphrase else forced_joinphrase),
(len(artists_credits_tuple) if not forced_index else forced_index),
forced_artist,
)
)
# if "name" is empty or didn't split
if not raw_artists_credits:
credit = forced_artist.name if forced_artist else artist_string
artists_credits_tuple.append(
(
credit,
("" if not forced_joinphrase else forced_joinphrase),
(0 if not forced_index else forced_index),
forced_artist,
)
)
return artists_credits_tuple
def get_or_create_artists_credits_from_artist_credit_metadata(
artists_credits_data, attributed_to, from_activity_id
):
artists_credits = []
for i, ac in enumerate(artists_credits_data):
ac["artist"] = get_or_create_artist(ac, attributed_to, from_activity_id)
credit = ac.get("credit", ac["artist"].name)
query = (
Q(artist=ac["artist"])
& Q(credit=credit)
& Q(joinphrase=ac["joinphrase"])
& Q(index=i)
)
artist_credit, created = get_best_candidate_or_create(
models.ArtistCredit, query, ac, ["artist", "credit", "joinphrase"]
)
artists_credits.append(artist_credit)
return artists_credits
@receiver(signals.upload_import_status_updated) @receiver(signals.upload_import_status_updated)
def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs): def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs):
user = upload.library.actor.get_user() user = upload.library.actor.get_user()
@ -895,7 +1063,7 @@ def get_prunable_albums():
def get_prunable_artists(): def get_prunable_artists():
return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True) return models.Artist.objects.filter(artist_credit__isnull=True)
def update_library_entity(obj, data): def update_library_entity(obj, data):
@ -926,8 +1094,8 @@ UPDATE_CONFIG = {
) )
}, },
}, },
"artists": {},
"album": {"title": {}, "mbid": {}, "release_date": {}}, "album": {"title": {}, "mbid": {}, "release_date": {}},
"artist": {"name": {}, "mbid": {}},
"album_artist": {"name": {}, "mbid": {}}, "album_artist": {"name": {}, "mbid": {}},
} }
@ -941,11 +1109,15 @@ def update_track_metadata(audio_metadata, track):
to_update = [ to_update = [
("track", track, lambda data: data), ("track", track, lambda data: data),
("album", track.album, lambda data: data["album"]), ("album", track.album, lambda data: data["album"]),
("artist", track.artist, lambda data: data["artists"][0]), (
"artist_credit",
track.artist_credit.all(),
lambda data: data["artist_credit"],
),
( (
"album_artist", "album_artist",
track.album.artist if track.album else None, track.album.artist_credit.all() if track.album else None,
lambda data: data["album"]["artists"][0], lambda data: data["album"]["artist_credit"],
), ),
] ]
for id, obj, data_getter in to_update: for id, obj, data_getter in to_update:
@ -956,6 +1128,54 @@ def update_track_metadata(audio_metadata, track):
obj_data = data_getter(new_data) obj_data = data_getter(new_data)
except IndexError: except IndexError:
continue continue
if id == "artist_credit":
if new_data.get("mbid", False):
logger.warning(
"If a track mbid is provided, it will be use to generate artist_credit \
information. If you want to set a custom artist_credit you nee to remove the track mbid"
)
track_artists_credits = get_or_create_artists_credits_from_musicbrainz(
"recording", new_data.get("mbid"), None, None
)
else:
track_artists_credits = (
get_or_create_artists_credits_from_artist_credit_metadata(
obj_data,
None,
None,
)
)
if track_artists_credits == obj:
continue
track.artist_credit.set(track_artists_credits)
continue
if id == "album_artist":
if new_data["album"].get("mbid", False):
logger.warning(
"If a album mbid is provided, it will be use to generate album artist_credit \
information. If you want to set a custom artist_credit you nee to remove the track mbid"
)
album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
"release", new_data["album"].get("mbid"), None, None
)
else:
album_artists_credits = (
get_or_create_artists_credits_from_artist_credit_metadata(
obj_data,
None,
None,
)
)
if album_artists_credits == obj:
continue
track.album.artist_credit.set(album_artists_credits)
continue
for field, config in UPDATE_CONFIG[id].items(): for field, config in UPDATE_CONFIG[id].items():
getter = config.get( getter = config.get(
"getter", lambda data, field: data[config.get("field", field)] "getter", lambda data, field: data[config.get("field", field)]
@ -972,7 +1192,6 @@ def update_track_metadata(audio_metadata, track):
if obj_updated_fields: if obj_updated_fields:
obj.save(update_fields=obj_updated_fields) obj.save(update_fields=obj_updated_fields)
tags_models.set_tags(track, *new_data.get("tags", [])) tags_models.set_tags(track, *new_data.get("tags", []))
if track.album and "album" in new_data and new_data["album"].get("cover_data"): if track.album and "album" in new_data and new_data["album"].get("cover_data"):

View File

@ -162,3 +162,10 @@ def browse_dir(root, path):
files.append({"name": el, "dir": False}) files.append({"name": el, "dir": False})
return dirs + files return dirs + files
def get_artist_credit_string(obj):
final_credit = ""
for ac in obj.artist_credit.all():
final_credit = final_credit + ac.credit + ac.joinphrase
return final_credit

View File

@ -120,16 +120,12 @@ class ArtistViewSet(
): ):
queryset = ( queryset = (
models.Artist.objects.all() models.Artist.objects.all()
.prefetch_related("attributed_to", "attachment_cover") .select_related("attributed_to", "attachment_cover")
.order_by("-id")
.prefetch_related( .prefetch_related(
"channel__actor", "channel__actor",
Prefetch(
"tracks",
queryset=models.Track.objects.all(),
to_attr="_prefetched_tracks",
),
) )
.order_by("-id") .annotate(_tracks_count=Count("artist_credit__tracks"))
) )
serializer_class = serializers.ArtistWithAlbumsSerializer serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [oauth_permissions.ScopePermission] permission_classes = [oauth_permissions.ScopePermission]
@ -166,12 +162,12 @@ class ArtistViewSet(
utils.get_actor_from_request(self.request) utils.get_actor_from_request(self.request)
) )
return queryset.prefetch_related( return queryset.prefetch_related(
Prefetch("albums", queryset=albums), TAG_PREFETCH Prefetch("artist_credit__albums", queryset=albums), TAG_PREFETCH
) )
libraries = get_libraries( libraries = get_libraries(
lambda o, uploads: uploads.filter( lambda o, uploads: uploads.filter(
Q(track__artist=o) | Q(track__album__artist=o) Q(track__artist_credit__artist=o) | Q(track__album__artist_credit__artist=o)
) )
) )
@ -186,8 +182,9 @@ class AlbumViewSet(
queryset = ( queryset = (
models.Album.objects.all() models.Album.objects.all()
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related("artist__channel", "attributed_to", "attachment_cover") .select_related("attributed_to", "attachment_cover")
) .prefetch_related("artist_credit__artist__channel")
).distinct()
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
permission_classes = [oauth_permissions.ScopePermission] permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries" required_scope = "libraries"
@ -219,8 +216,8 @@ class AlbumViewSet(
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action in ["destroy"]: if self.action in ["destroy"]:
queryset = queryset.exclude(artist__channel=None).filter( queryset = queryset.exclude(artist_credit__artist__channel=None).filter(
artist__attributed_to=self.request.user.actor artist_credit__artist__attributed_to=self.request.user.actor
) )
tracks = models.Track.objects.all().prefetch_related("album") tracks = models.Track.objects.all().prefetch_related("album")
@ -246,6 +243,54 @@ class AlbumViewSet(
) )
models.Album.objects.filter(pk=instance.pk).delete() models.Album.objects.filter(pk=instance.pk).delete()
@transaction.atomic
def create(self, request, *args, **kwargs):
request_data = request.data.copy()
if mbid := request_data.get("musicbrainz_albumid", None) or request_data.get(
"mbid", None
):
artist_credit = tasks.get_or_create_artists_credits_from_musicbrainz(
"release",
mbid,
attributed_to=request.user.actor,
from_activity_id=None,
)
else:
artist_data = request_data.pop("artist", False)
artist_credit_data = request_data.pop("artist_credit", False)
if not artist_data and not artist_credit_data:
return Response({}, status=400)
if artist_data:
try:
artist = models.Artist.objects.get(pk=artist_data)
except models.Artist.DoesNotExist:
return Response({"détail": "artist id not found"}, status=400)
artist_credit, created = models.ArtistCredit.objects.get_or_create(
**{
"artist": artist,
"credit": artist.name,
"joinphrase": "",
"index": 0,
}
)
elif artist_credit_data:
try:
artist_credit = models.ArtistCredit.objects.get(
pk=artist_credit_data
)
except models.ArtistCredit.DoesNotExist:
return Response(
{"détail": "artist_credit id not found"}, status=400
)
request_data["artist_credit"] = [artist_credit.pk]
serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return Response(serializer.data, status=204)
class LibraryViewSet( class LibraryViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
@ -404,7 +449,7 @@ class TrackViewSet(
.for_nested_serialization() .for_nested_serialization()
.prefetch_related("attributed_to", "attachment_cover") .prefetch_related("attributed_to", "attachment_cover")
.order_by("-creation_date") .order_by("-creation_date")
) ).distinct()
serializer_class = serializers.TrackSerializer serializer_class = serializers.TrackSerializer
permission_classes = [oauth_permissions.ScopePermission] permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries" required_scope = "libraries"
@ -426,8 +471,8 @@ class TrackViewSet(
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action in ["destroy"]: if self.action in ["destroy"]:
queryset = queryset.exclude(artist__channel=None).filter( queryset = queryset.exclude(artist_credit__artist__channel=None).filter(
artist__attributed_to=self.request.user.actor artist_credit__artist__attributed_to=self.request.user.actor
) )
filter_favorites = self.request.GET.get("favorites", None) filter_favorites = self.request.GET.get("favorites", None)
user = self.request.user user = self.request.user
@ -653,7 +698,9 @@ class ListenMixin(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
def handle_stream(track, request, download, explicit_file, format, max_bitrate): def handle_stream(track, request, download, explicit_file, format, max_bitrate):
actor = utils.get_actor_from_request(request) actor = utils.get_actor_from_request(request)
queryset = track.uploads.prefetch_related("track__album__artist", "track__artist") queryset = track.uploads.prefetch_related(
"track__album__artist_credit__artist", "track__artist_credit"
)
if explicit_file: if explicit_file:
queryset = queryset.filter(uuid=explicit_file) queryset = queryset.filter(uuid=explicit_file)
queryset = queryset.playable_by(actor) queryset = queryset.playable_by(actor)
@ -723,8 +770,8 @@ class UploadViewSet(
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related( .prefetch_related(
"library__actor", "library__actor",
"track__artist", "track__artist_credit",
"track__album__artist", "track__album__artist_credit",
"track__attachment_cover", "track__attachment_cover",
) )
) )
@ -743,7 +790,7 @@ class UploadViewSet(
"import_date", "import_date",
"bitrate", "bitrate",
"size", "size",
"artist__name", "artist_credit__artist__name",
) )
def get_queryset(self): def get_queryset(self):
@ -846,20 +893,24 @@ class Search(views.APIView):
def get_tracks(self, query): def get_tracks(self, query):
query_obj = utils.get_fts_query( query_obj = utils.get_fts_query(
query, query,
fts_fields=["body_text", "album__body_text", "artist__body_text"], fts_fields=[
"body_text",
"album__body_text",
"artist_credit__artist__body_text",
],
model=models.Track, model=models.Track,
) )
qs = ( qs = (
models.Track.objects.all() models.Track.objects.all()
.filter(query_obj) .filter(query_obj)
.prefetch_related( .prefetch_related(
"artist", "artist_credit",
"attributed_to", "attributed_to",
Prefetch( Prefetch(
"album", "album",
queryset=models.Album.objects.select_related( queryset=models.Album.objects.select_related(
"artist", "attachment_cover", "attributed_to" "attachment_cover", "attributed_to"
).prefetch_related("tracks"), ).prefetch_related("tracks", "artist_credit"),
), ),
) )
) )
@ -867,13 +918,15 @@ class Search(views.APIView):
def get_albums(self, query): def get_albums(self, query):
query_obj = utils.get_fts_query( query_obj = utils.get_fts_query(
query, fts_fields=["body_text", "artist__body_text"], model=models.Album query,
fts_fields=["body_text", "artist_credit__artist__body_text"],
model=models.Album,
) )
qs = ( qs = (
models.Album.objects.all() models.Album.objects.all()
.filter(query_obj) .filter(query_obj)
.select_related("artist", "attachment_cover", "attributed_to") .select_related("attachment_cover", "attributed_to")
.prefetch_related("tracks__artist") .prefetch_related("tracks__artist_credit", "artist_credit")
) )
return common_utils.order_for_search(qs, "title")[: self.max_results] return common_utils.order_for_search(qs, "title")[: self.max_results]

View File

@ -7,6 +7,7 @@ from funkwhale_api import __version__
_api = musicbrainzngs _api = musicbrainzngs
_api.set_useragent("funkwhale", str(__version__), settings.FUNKWHALE_URL) _api.set_useragent("funkwhale", str(__version__), settings.FUNKWHALE_URL)
_api.set_hostname(settings.MUSICBRAINZ_HOSTNAME) _api.set_hostname(settings.MUSICBRAINZ_HOSTNAME)
_api.set_format(fmt="json")
def clean_artist_search(query, **kwargs): def clean_artist_search(query, **kwargs):

View File

@ -22,7 +22,7 @@ class PlaylistFilter(filters.FilterSet):
distinct=True, distinct=True,
) )
artist = filters.ModelChoiceFilter( artist = filters.ModelChoiceFilter(
"playlist_tracks__track__artist", "playlist_tracks__track__artist_credit__artist",
queryset=music_models.Artist.objects.all(), queryset=music_models.Artist.objects.all(),
distinct=True, distinct=True,
) )

View File

@ -191,8 +191,11 @@ class Playlist(models.Model):
class PlaylistTrackQuerySet(models.QuerySet): class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self, actor=None): def for_nested_serialization(self, actor=None):
tracks = music_models.Track.objects.with_playable_uploads(actor) tracks = music_models.Track.objects.with_playable_uploads(actor)
tracks = tracks.select_related( tracks = tracks.prefetch_related(
"artist", "album__artist", "album__attachment_cover", "attributed_to" "artist_credit__artist",
"album__artist_credit__artist",
"album__attachment_cover",
"attributed_to",
) )
return self.prefetch_related( return self.prefetch_related(
models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track") models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")

View File

@ -95,7 +95,9 @@ class PlaylistSerializer(serializers.ModelSerializer):
covers = [] covers = []
max_covers = 5 max_covers = 5
for plt in plts: for plt in plts:
if plt.track.album.artist_id in excluded_artists: if [
ac.artist.pk for ac in plt.track.album.artist_credit.all()
] in excluded_artists:
continue continue
url = plt.track.album.attachment_cover.download_url_medium_square_crop url = plt.track.album.attachment_cover.download_url_medium_square_crop
if url in covers: if url in covers:

View File

@ -158,7 +158,7 @@ class ArtistFilter(RadioFilter):
return filter_config return filter_config
def get_query(self, candidates, ids, **kwargs): def get_query(self, candidates, ids, **kwargs):
return Q(artist__pk__in=ids) return Q(artist_credit__artist__pk__in=ids)
def validate(self, config): def validate(self, config):
super().validate(config) super().validate(config)
@ -199,8 +199,8 @@ class TagFilter(RadioFilter):
def get_query(self, candidates, names, **kwargs): def get_query(self, candidates, names, **kwargs):
return ( return (
Q(tagged_items__tag__name__in=names) Q(tagged_items__tag__name__in=names)
| Q(artist__tagged_items__tag__name__in=names) | Q(artist_credit__artist__tagged_items__tag__name__in=names)
| Q(album__tagged_items__tag__name__in=names) | Q(artist_credit__albums__tagged_items__tag__name__in=names)
) )
def clean_config(self, filter_config): def clean_config(self, filter_config):

View File

@ -66,13 +66,21 @@ class SessionRadio(SimpleRadio):
return ( return (
Track.objects.all() Track.objects.all()
.with_playable_uploads(actor=None) .with_playable_uploads(actor=None)
.select_related("artist", "album__artist", "attributed_to") .prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
"attributed_to",
)
) )
else: else:
qs = ( qs = (
Track.objects.all() Track.objects.all()
.with_playable_uploads(self.session.user.actor) .with_playable_uploads(self.session.user.actor)
.select_related("artist", "album__artist", "attributed_to") .prefetch_related(
"artist_credit__artist",
"album__artist_credit__artist",
"attributed_to",
)
) )
query = moderation_filters.get_filtered_content_query( query = moderation_filters.get_filtered_content_query(
@ -124,7 +132,7 @@ class SessionRadio(SimpleRadio):
class RandomRadio(SessionRadio): class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(artist__content_category="music").order_by("?") return qs.filter(artist_credit__artist__content_category="music").order_by("?")
@registry.register(name="random_library") @registry.register(name="random_library")
@ -134,7 +142,9 @@ class RandomLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list( tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True "id", flat=True
) )
query = Q(artist__content_category="music") & Q(pk__in=tracks_ids) query = Q(artist_credit__artist__content_category="music") & Q(
pk__in=tracks_ids
)
return qs.filter(query).order_by("?") return qs.filter(query).order_by("?")
@ -149,7 +159,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True) track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
return qs.filter(pk__in=track_ids, artist__content_category="music") return qs.filter(
pk__in=track_ids, artist_credit__artist__content_category="music"
)
@registry.register(name="custom") @registry.register(name="custom")
@ -240,8 +252,8 @@ class TagRadio(RelatedObjectRadio):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
query = ( query = (
Q(tagged_items__tag=self.session.related_object) Q(tagged_items__tag=self.session.related_object)
| Q(artist__tagged_items__tag=self.session.related_object) | Q(artist_credit__artist__tagged_items__tag=self.session.related_object)
| Q(album__tagged_items__tag=self.session.related_object) | Q(artist_credit__albums__tagged_items__tag=self.session.related_object)
) )
return qs.filter(query) return qs.filter(query)
@ -323,7 +335,7 @@ class ArtistRadio(RelatedObjectRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(artist=self.session.related_object) return qs.filter(artist_credit__artist=self.session.related_object)
@registry.register(name="less-listened") @registry.register(name="less-listened")
@ -336,7 +348,7 @@ class LessListenedRadio(SessionRadio):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True) listened = self.session.user.listenings.all().values_list("track", flat=True)
return ( return (
qs.filter(artist__content_category="music") qs.filter(artist_credit__artist__content_category="music")
.exclude(pk__in=listened) .exclude(pk__in=listened)
.order_by("?") .order_by("?")
) )
@ -354,7 +366,9 @@ class LessListenedLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list( tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True "id", flat=True
) )
query = Q(artist__content_category="music") & Q(pk__in=tracks_ids) query = Q(artist_credit__artist__content_category="music") & Q(
pk__in=tracks_ids
)
return qs.filter(query).exclude(pk__in=listened).order_by("?") return qs.filter(query).exclude(pk__in=listened).order_by("?")
@ -410,7 +424,7 @@ class RecentlyAdded(SessionRadio):
date = datetime.date.today() - datetime.timedelta(days=30) date = datetime.date.today() - datetime.timedelta(days=30)
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter( return qs.filter(
Q(artist__content_category="music"), Q(artist_credit__artist__content_category="music"),
Q(creation_date__gt=date), Q(creation_date__gt=date),
) )

View File

@ -63,7 +63,9 @@ class SessionRadio(SimpleRadio):
qs = ( qs = (
Track.objects.all() Track.objects.all()
.with_playable_uploads(actor=actor) .with_playable_uploads(actor=actor)
.select_related("artist", "album__artist", "attributed_to") .prefetch_related(
"artist_credit__artist", "album__artist_credit__artist", "attributed_to"
)
) )
query = moderation_filters.get_filtered_content_query( query = moderation_filters.get_filtered_content_query(
@ -164,7 +166,7 @@ class SessionRadio(SimpleRadio):
class RandomRadio(SessionRadio): class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(artist__content_category="music").order_by("?") return qs.filter(artist_credit__artist__content_category="music").order_by("?")
@registry.register(name="random_library") @registry.register(name="random_library")
@ -174,7 +176,9 @@ class RandomLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list( tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True "id", flat=True
) )
query = Q(artist__content_category="music") & Q(pk__in=tracks_ids) query = Q(artist_credit__artist__content_category="music") & Q(
pk__in=tracks_ids
)
return qs.filter(query).order_by("?") return qs.filter(query).order_by("?")
@ -189,7 +193,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True) track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
return qs.filter(pk__in=track_ids, artist__content_category="music") return qs.filter(
pk__in=track_ids, artist_credit__artist__content_category="music"
)
@registry.register(name="custom") @registry.register(name="custom")
@ -363,7 +369,7 @@ class ArtistRadio(RelatedObjectRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(artist=self.session.related_object) return qs.filter(artist_credit__artist=self.session.related_object)
@registry.register(name="less-listened") @registry.register(name="less-listened")
@ -376,7 +382,7 @@ class LessListenedRadio(SessionRadio):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True) listened = self.session.user.listenings.all().values_list("track", flat=True)
return ( return (
qs.filter(artist__content_category="music") qs.filter(artist_credit__artist__content_category="music")
.exclude(pk__in=listened) .exclude(pk__in=listened)
.order_by("?") .order_by("?")
) )
@ -394,7 +400,9 @@ class LessListenedLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list( tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True "id", flat=True
) )
query = Q(artist__content_category="music") & Q(pk__in=tracks_ids) query = Q(artist_credit__artist__content_category="music") & Q(
pk__in=tracks_ids
)
return qs.filter(query).exclude(pk__in=listened).order_by("?") return qs.filter(query).exclude(pk__in=listened).order_by("?")
@ -450,7 +458,7 @@ class RecentlyAdded(SessionRadio):
date = datetime.date.today() - datetime.timedelta(days=30) date = datetime.date.today() - datetime.timedelta(days=30)
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter( return qs.filter(
Q(artist__content_category="music"), Q(artist_credit__artist__content_category="music"),
Q(creation_date__gt=date), Q(creation_date__gt=date),
) )

View File

@ -14,7 +14,7 @@ class AlbumList2FilterSet(filters.FilterSet):
ORDERING = { ORDERING = {
"random": "?", "random": "?",
"newest": "-creation_date", "newest": "-creation_date",
"alphabeticalByArtist": "artist__name", "alphabeticalByArtist": "artist_credit__artist__name",
"alphabeticalByName": "title", "alphabeticalByName": "title",
} }
if value not in ORDERING: if value not in ORDERING:

View File

@ -36,7 +36,7 @@ def get_valid_filepart(s):
def get_track_path(track, suffix): def get_track_path(track, suffix):
parts = [] parts = []
parts.append(get_valid_filepart(track.artist.name)) parts.append(get_valid_filepart(track.get_artist_credit_string))
if track.album: if track.album:
parts.append(get_valid_filepart(track.album.title)) parts.append(get_valid_filepart(track.album.title))
track_part = get_valid_filepart(track.title) + "." + suffix track_part = get_valid_filepart(track.title) + "." + suffix
@ -79,7 +79,7 @@ class GetArtistsSerializer(serializers.Serializer):
class GetArtistSerializer(serializers.Serializer): class GetArtistSerializer(serializers.Serializer):
def to_representation(self, artist): def to_representation(self, artist):
albums = artist.albums.prefetch_related("tracks__uploads") albums = artist.artist_credit.albums().prefetch_related("tracks__uploads")
payload = { payload = {
"id": artist.pk, "id": artist.pk,
"name": artist.name, "name": artist.name,
@ -128,7 +128,7 @@ def get_track_data(album, track, upload):
"isDir": "false", "isDir": "false",
"title": track.title, "title": track.title,
"album": album.title if album else "", "album": album.title if album else "",
"artist": track.artist.name, "artist": track.get_artist_credit_string,
"track": track.position or 1, "track": track.position or 1,
"discNumber": track.disc_number or 1, "discNumber": track.disc_number or 1,
# Ugly fallback to mp3 but some subsonic clients fail if the value is empty or null, and we don't always # Ugly fallback to mp3 but some subsonic clients fail if the value is empty or null, and we don't always
@ -144,7 +144,11 @@ def get_track_data(album, track, upload):
"duration": upload.duration or 0, "duration": upload.duration or 0,
"created": to_subsonic_date(track.creation_date), "created": to_subsonic_date(track.creation_date),
"albumId": album.pk if album else "", "albumId": album.pk if album else "",
"artistId": album.artist.pk if album else track.artist.pk, "artistId": (
album.artist_credit.all()[0].artist.pk
if album
else track.artist_credit.all()[0].artist.pk
),
"type": "music", "type": "music",
"mediaType": "song", "mediaType": "song",
"musicBrainzId": str(track.mbid or ""), "musicBrainzId": str(track.mbid or ""),
@ -165,9 +169,9 @@ def get_track_data(album, track, upload):
def get_album2_data(album): def get_album2_data(album):
payload = { payload = {
"id": album.id, "id": album.id,
"artistId": album.artist.id, "artistId": album.artist_credit.all()[0].artist.pk,
"name": album.title, "name": album.title,
"artist": album.artist.name, "artist": album.get_artist_credit_string,
"created": to_subsonic_date(album.creation_date), "created": to_subsonic_date(album.creation_date),
"duration": album.duration, "duration": album.duration,
"playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0, "playCount": album.tracks.aggregate(l=Count("listenings"))["l"] or 0,
@ -226,7 +230,7 @@ def get_starred_tracks_data(favorites):
by_track_id = {f.track_id: f for f in favorites} by_track_id = {f.track_id: f for f in favorites}
tracks = ( tracks = (
music_models.Track.objects.filter(pk__in=by_track_id.keys()) music_models.Track.objects.filter(pk__in=by_track_id.keys())
.select_related("album__artist") .prefetch_related("album__artist_credit__artist")
.prefetch_related("uploads") .prefetch_related("uploads")
) )
tracks = tracks.order_by("-creation_date") tracks = tracks.order_by("-creation_date")
@ -261,7 +265,7 @@ def get_playlist_data(playlist):
def get_playlist_detail_data(playlist): def get_playlist_detail_data(playlist):
data = get_playlist_data(playlist) data = get_playlist_data(playlist)
qs = ( qs = (
playlist.playlist_tracks.select_related("track__album__artist") playlist.playlist_tracks.prefetch_related("track__album__artist_credit__artist")
.prefetch_related("track__uploads") .prefetch_related("track__uploads")
.order_by("index") .order_by("index")
) )

View File

@ -278,7 +278,9 @@ class SubsonicViewSet(viewsets.GenericViewSet):
detail=False, methods=["get", "post"], url_name="get_album", url_path="getAlbum" detail=False, methods=["get", "post"], url_name="get_album", url_path="getAlbum"
) )
@find_object( @find_object(
music_models.Album.objects.with_duration().select_related("artist"), music_models.Album.objects.with_duration().prefetch_related(
"artist_credit__artist"
),
filter_playable=True, filter_playable=True,
) )
def get_album(self, request, *args, **kwargs): def get_album(self, request, *args, **kwargs):
@ -292,7 +294,9 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def stream(self, request, *args, **kwargs): def stream(self, request, *args, **kwargs):
data = request.GET or request.POST data = request.GET or request.POST
track = kwargs.pop("obj") track = kwargs.pop("obj")
queryset = track.uploads.select_related("track__album__artist", "track__artist") queryset = track.uploads.prefetch_related(
"track__album__artist_credit__artist", "track__artist_credit__artist"
)
sorted_uploads = music_serializers.sort_uploads_for_listen(queryset) sorted_uploads = music_serializers.sort_uploads_for_listen(queryset)
if not sorted_uploads: if not sorted_uploads:
@ -416,9 +420,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
queryset.playable_by(actor) queryset.playable_by(actor)
.filter( .filter(
Q(tagged_items__tag__name=genre) Q(tagged_items__tag__name=genre)
| Q(artist__tagged_items__tag__name=genre) | Q(artist_credit__artist__tagged_items__tag__name=genre)
| Q(album__artist__tagged_items__tag__name=genre) | Q(
| Q(album__tagged_items__tag__name=genre) artist_credit__albums__artist_credit__artist__tagged_items__tag__name=genre
)
| Q(artist_credit__albums__tagged_items__tag__name=genre)
) )
.prefetch_related("uploads") .prefetch_related("uploads")
.distinct() .distinct()
@ -457,7 +463,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
) )
.with_tracks_count() .with_tracks_count()
.with_duration() .with_duration()
.order_by("artist__name") .order_by("artist_credit__artist__name")
) )
data = request.GET or request.POST data = request.GET or request.POST
filterset = filters.AlbumList2FilterSet(data, queryset=queryset) filterset = filters.AlbumList2FilterSet(data, queryset=queryset)
@ -480,7 +486,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
genre = data.get("genre") genre = data.get("genre")
queryset = queryset.filter( queryset = queryset.filter(
Q(tagged_items__tag__name=genre) Q(tagged_items__tag__name=genre)
| Q(artist__tagged_items__tag__name=genre) | Q(artist_credit__artist__tagged_items__tag__name=genre)
) )
elif type == "byYear": elif type == "byYear":
try: try:
@ -549,7 +555,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"queryset": ( "queryset": (
music_models.Album.objects.with_duration() music_models.Album.objects.with_duration()
.with_tracks_count() .with_tracks_count()
.select_related("artist") .prefetch_related("artist_credit__artist")
), ),
"serializer": serializers.get_album_list2_data, "serializer": serializers.get_album_list2_data,
}, },
@ -559,7 +565,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"queryset": ( "queryset": (
music_models.Track.objects.prefetch_related( music_models.Track.objects.prefetch_related(
"uploads" "uploads"
).select_related("album__artist") ).prefetch_related("artist_credit__albums__artist_credit__artist")
), ),
"serializer": serializers.get_song_list_data, "serializer": serializers.get_song_list_data,
}, },

View File

@ -22,19 +22,21 @@ def get_tags_from_foreign_key(
""" """
data = {} data = {}
objs = foreign_key_model.objects.filter( objs = foreign_key_model.objects.filter(
**{f"{foreign_key_attr}__pk__in": ids} **{f"artist_credit__{foreign_key_attr}__pk__in": ids}
).order_by("-id") ).order_by("-id")
objs = objs.only("id", f"{foreign_key_attr}_id").prefetch_related(tagged_items_attr) objs = objs.only("id", f"artist_credit__{foreign_key_attr}_id").prefetch_related(
tagged_items_attr
)
for obj in objs.iterator(): for obj in objs.iterator():
# loop on all objects, store the objs tags + counter on the corresponding foreign key for ac in obj.artist_credit.all():
row_data = data.setdefault( # loop on all objects, store the objs tags + counter on the corresponding foreign key
getattr(obj, f"{foreign_key_attr}_id"), row_data = data.setdefault(
{"total_objs": 0, "tags": []}, getattr(ac, f"{foreign_key_attr}_id"),
) {"total_objs": 0, "tags": []},
row_data["total_objs"] += 1 )
for ti in getattr(obj, tagged_items_attr).all(): row_data["total_objs"] += 1
row_data["tags"].append(ti.tag_id) for ti in getattr(obj, tagged_items_attr).all():
row_data["tags"].append(ti.tag_id)
# now, keep only tags that are present on all objects, i.e tags where the count # now, keep only tags that are present on all objects, i.e tags where the count
# matches total_objs # matches total_objs

View File

@ -55,7 +55,7 @@ def add_tracks_to_index(tracks_pk):
document = dict() document = dict()
document["pk"] = track.pk document["pk"] = track.pk
document["combined"] = utils.delete_non_alnum_characters( document["combined"] = utils.delete_non_alnum_characters(
track.artist.name + track.title track.get_artist_credit_string + track.title
) )
documents.append(document) documents.append(document)

View File

@ -678,7 +678,7 @@ def test_rss_feed_item_serializer_create(factories):
assert upload.duration == 1357 assert upload.duration == 1357
assert upload.mimetype == "audio/mpeg" assert upload.mimetype == "audio/mpeg"
assert upload.track.uuid == expected_uuid assert upload.track.uuid == expected_uuid
assert upload.track.artist == channel.artist assert upload.track.artist_credit.all()[0].artist == channel.artist
assert upload.track.copyright == "test something" assert upload.track.copyright == "test something"
assert upload.track.position == 33 assert upload.track.position == 33
assert upload.track.disc_number == 2 assert upload.track.disc_number == 2
@ -702,7 +702,7 @@ def test_rss_feed_item_serializer_update(factories):
track__uuid=expected_uuid, track__uuid=expected_uuid,
source="https://file.domain/audio.mp3", source="https://file.domain/audio.mp3",
library=channel.library, library=channel.library,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
) )
track = upload.track track = upload.track
@ -748,7 +748,7 @@ def test_rss_feed_item_serializer_update(factories):
assert upload.duration == 1357 assert upload.duration == 1357
assert upload.mimetype == "audio/mpeg" assert upload.mimetype == "audio/mpeg"
assert upload.track.uuid == expected_uuid assert upload.track.uuid == expected_uuid
assert upload.track.artist == channel.artist assert upload.track.artist_credit.all()[0].artist == channel.artist
assert upload.track.copyright == "test something" assert upload.track.copyright == "test something"
assert upload.track.position == 33 assert upload.track.position == 33
assert upload.track.disc_number == 2 assert upload.track.disc_number == 2

View File

@ -1,115 +1,115 @@
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from funkwhale_api.cli import library, main, users from funkwhale_api.cli import library, main
@pytest.mark.parametrize( @pytest.mark.parametrize(
"cmd, args, handlers", "cmd, args, handlers",
[ [
( # (
("users", "create"), # ("users", "create"),
( # (
"--username", # "--username",
"testuser", # "testuser",
"--password", # "--password",
"testpassword", # "testpassword",
"--email", # "--email",
"test@hello.com", # "test@hello.com",
"--upload-quota", # "--upload-quota",
"35", # "35",
"--permission", # "--permission",
"library", # "library",
"--permission", # "--permission",
"moderation", # "moderation",
"--staff", # "--staff",
"--superuser", # "--superuser",
), # ),
[ # [
( # (
users, # users,
"handler_create_user", # "handler_create_user",
{ # {
"username": "testuser", # "username": "testuser",
"password": "testpassword", # "password": "testpassword",
"email": "test@hello.com", # "email": "test@hello.com",
"upload_quota": 35, # "upload_quota": 35,
"permissions": ("library", "moderation"), # "permissions": ("library", "moderation"),
"is_staff": True, # "is_staff": True,
"is_superuser": True, # "is_superuser": True,
}, # },
) # )
], # ],
), # ),
( # (
("users", "rm"), # ("users", "rm"),
("testuser1", "testuser2", "--no-input"), # ("testuser1", "testuser2", "--no-input"),
[ # [
( # (
users, # users,
"handler_delete_user", # "handler_delete_user",
{"usernames": ("testuser1", "testuser2"), "soft": True}, # {"usernames": ("testuser1", "testuser2"), "soft": True},
) # )
], # ],
), # ),
( # (
("users", "rm"), # ("users", "rm"),
( # (
"testuser1", # "testuser1",
"testuser2", # "testuser2",
"--no-input", # "--no-input",
"--hard", # "--hard",
), # ),
[ # [
( # (
users, # users,
"handler_delete_user", # "handler_delete_user",
{"usernames": ("testuser1", "testuser2"), "soft": False}, # {"usernames": ("testuser1", "testuser2"), "soft": False},
) # )
], # ],
), # ),
( # (
("users", "set"), # ("users", "set"),
( # (
"testuser1", # "testuser1",
"testuser2", # "testuser2",
"--no-input", # "--no-input",
"--inactive", # "--inactive",
"--upload-quota", # "--upload-quota",
"35", # "35",
"--no-staff", # "--no-staff",
"--superuser", # "--superuser",
"--permission-library", # "--permission-library",
"--no-permission-moderation", # "--no-permission-moderation",
"--no-permission-settings", # "--no-permission-settings",
"--password", # "--password",
"newpassword", # "newpassword",
), # ),
[ # [
( # (
users, # users,
"handler_update_user", # "handler_update_user",
{ # {
"usernames": ("testuser1", "testuser2"), # "usernames": ("testuser1", "testuser2"),
"kwargs": { # "kwargs": {
"is_active": False, # "is_active": False,
"upload_quota": 35, # "upload_quota": 35,
"is_staff": False, # "is_staff": False,
"is_superuser": True, # "is_superuser": True,
"permission_library": True, # "permission_library": True,
"permission_moderation": False, # "permission_moderation": False,
"permission_settings": False, # "permission_settings": False,
"password": "newpassword", # "password": "newpassword",
}, # },
}, # },
) # )
], # ],
), # ),
( # (
("albums", "add-tags-from-tracks"), # ("albums", "add-tags-from-tracks"),
tuple(), # tuple(),
[(library, "handler_add_tags_from_tracks", {"albums": True})], # [(library, "handler_add_tags_from_tracks", {"albums": True})],
), # ),
( (
("artists", "add-tags-from-tracks"), ("artists", "add-tags-from-tracks"),
tuple(), tuple(),

View File

@ -23,6 +23,13 @@ def test_load_test_data_dry_run(factories, mocker):
{"create_dependencies": True, "artists": 10}, {"create_dependencies": True, "artists": 10},
[(music_models.Artist.objects.all(), 10)], [(music_models.Artist.objects.all(), 10)],
), ),
(
{"create_dependencies": True, "artist_credit": 1, "artists": 1},
[
(music_models.ArtistCredit.objects.all(), 1),
(music_models.Artist.objects.all(), 1),
],
),
( (
{"create_dependencies": True, "albums": 10, "artists": 1}, {"create_dependencies": True, "albums": 10, "artists": 1},
[ [
@ -39,10 +46,14 @@ def test_load_test_data_dry_run(factories, mocker):
], ],
), ),
( (
{"create_dependencies": True, "albums": 10, "albums_artist_factor": 0.5}, {
"create_dependencies": True,
"albums": 10,
"albums_artist_credit_factor": 0.5,
},
[ [
(music_models.Album.objects.all(), 10), (music_models.Album.objects.all(), 10),
(music_models.Artist.objects.all(), 5), (music_models.ArtistCredit.objects.all(), 5),
], ],
), ),
( (
@ -95,7 +106,7 @@ def test_load_test_data_args(factories, kwargs, expected_counts, mocker):
def test_load_test_data_skip_dependencies(factories): def test_load_test_data_skip_dependencies(factories):
factories["music.Artist"].create_batch(size=5) factories["music.ArtistCredit"].create_batch(size=5)
call_command("load_test_data", dry_run=False, albums=10, create_dependencies=False) call_command("load_test_data", dry_run=False, albums=10, create_dependencies=False)
assert music_models.Artist.objects.count() == 5 assert music_models.Artist.objects.count() == 5

View File

@ -64,7 +64,7 @@ def test_attachment(factories, now):
def test_attachment_queryset_attached(args, expected, factories, queryset_equal_list): def test_attachment_queryset_attached(args, expected, factories, queryset_equal_list):
attachments = [ attachments = [
factories["music.Album"]( factories["music.Album"](
with_cover=True, artist__attachment_cover=None with_cover=True, artist_credit__artist__attachment_cover=None
).attachment_cover, ).attachment_cover,
factories["common.Attachment"](), factories["common.Attachment"](),
] ]

View File

@ -4,7 +4,9 @@ from funkwhale_api.favorites import filters, models
def test_track_favorite_filter_track_artist(factories, mocker, queryset_equal_list): def test_track_favorite_filter_track_artist(factories, mocker, queryset_equal_list):
factories["favorites.TrackFavorite"]() factories["favorites.TrackFavorite"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_fav = factories["favorites.TrackFavorite"](track__artist=cf.target_artist) hidden_fav = factories["favorites.TrackFavorite"](
track__artist_credit__artist=cf.target_artist
)
qs = models.TrackFavorite.objects.all() qs = models.TrackFavorite.objects.all()
filterset = filters.TrackFavoriteFilter( filterset = filters.TrackFavoriteFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
@ -19,7 +21,7 @@ def test_track_favorite_filter_track_album_artist(
factories["favorites.TrackFavorite"]() factories["favorites.TrackFavorite"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_fav = factories["favorites.TrackFavorite"]( hidden_fav = factories["favorites.TrackFavorite"](
track__album__artist=cf.target_artist track__album__artist_credit__artist=cf.target_artist
) )
qs = models.TrackFavorite.objects.all() qs = models.TrackFavorite.objects.all()
filterset = filters.TrackFavoriteFilter( filterset = filters.TrackFavoriteFilter(

View File

@ -353,7 +353,7 @@ def test_inbox_create_audio(factories, mocker):
def test_inbox_create_audio_channel(factories, mocker): def test_inbox_create_audio_channel(factories, mocker):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
upload = factories["music.Upload"]( upload = factories["music.Upload"](
track__album=album, track__album=album,
library=channel.library, library=channel.library,
@ -423,7 +423,7 @@ def test_inbox_delete_album(factories):
def test_inbox_delete_album_channel(factories): def test_inbox_delete_album_channel(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
payload = { payload = {
"type": "Delete", "type": "Delete",
"actor": channel.actor.fid, "actor": channel.actor.fid,
@ -454,7 +454,7 @@ def test_outbox_delete_album(factories):
def test_outbox_delete_album_channel(factories): def test_outbox_delete_album_channel(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
a = list(routes.outbox_delete_album({"album": album}))[0] a = list(routes.outbox_delete_album({"album": album}))[0]
expected = serializers.ActivitySerializer( expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}} {"type": "Delete", "object": {"type": "Album", "id": album.fid}}
@ -570,7 +570,7 @@ def test_inbox_delete_audio(factories):
def test_inbox_delete_audio_channel(factories): def test_inbox_delete_audio_channel(factories):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
upload = factories["music.Upload"](track__artist=channel.artist) upload = factories["music.Upload"](track__artist_credit__artist=channel.artist)
payload = { payload = {
"type": "Delete", "type": "Delete",
"actor": channel.actor.fid, "actor": channel.actor.fid,
@ -816,7 +816,7 @@ def test_inbox_update_audio(factories, mocker, r_mock):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
upload = factories["music.Upload"]( upload = factories["music.Upload"](
library=channel.library, library=channel.library,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
track__attributed_to=channel.actor, track__attributed_to=channel.actor,
) )
upload.track.fid = upload.fid upload.track.fid = upload.fid

View File

@ -787,11 +787,11 @@ def test_activity_pub_album_serializer_to_ap(factories):
"musicbrainzId": album.mbid, "musicbrainzId": album.mbid,
"published": album.creation_date.isoformat(), "published": album.creation_date.isoformat(),
"released": album.release_date.isoformat(), "released": album.release_date.isoformat(),
"artists": [ "artist_credit": serializers.ArtistCreditSerializer(
serializers.ArtistSerializer( album.artist_credit.all(),
album.artist, context={"include_ap_context": False} many=True,
).data context={"include_ap_context": False},
], ).data,
"attributedTo": album.attributed_to.fid, "attributedTo": album.attributed_to.fid,
"mediaType": "text/html", "mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type), "content": common_utils.render_html(content.text, content.content_type),
@ -808,19 +808,39 @@ def test_activity_pub_album_serializer_to_ap(factories):
def test_activity_pub_album_serializer_to_ap_channel_artist(factories): def test_activity_pub_album_serializer_to_ap_channel_artist(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
album = factories["music.Album"]( album = factories["music.Album"](
artist=channel.artist, artist_credit__artist=channel.artist,
) )
serializer = serializers.AlbumSerializer(album) serializer = serializers.AlbumSerializer(album)
assert serializer.data["artist_credit"] == [
assert serializer.data["artists"] == [ {
{"type": channel.actor.type, "id": channel.actor.fid} "type": "ArtistCredit",
"id": album.artist_credit.all()[0].fid,
"artist": {
"type": "Artist",
"id": album.artist_credit.all()[0].artist.fid,
"name": album.artist_credit.all()[0].artist.name,
"published": album.artist_credit.all()[
0
].artist.creation_date.isoformat(),
"musicbrainzId": str(album.artist_credit.all()[0].artist.mbid),
"attributedTo": album.artist_credit.all()[0].artist.attributed_to.fid,
"tag": [],
"image": None,
},
"joinphrase": "",
"credit": album.artist_credit.all()[0].credit,
"index": None,
"published": album.artist_credit.all()[0].creation_date.isoformat(),
}
] ]
def test_activity_pub_album_serializer_from_ap_create(factories, faker, now): def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
artist_credit = factories["music.ArtistCredit"](artist=artist)
released = faker.date_object() released = faker.date_object()
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
@ -831,11 +851,9 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
"musicbrainzId": faker.uuid4(), "musicbrainzId": faker.uuid4(),
"published": now.isoformat(), "published": now.isoformat(),
"released": released.isoformat(), "released": released.isoformat(),
"artists": [ "artist_credit": serializers.ArtistCreditSerializer(
serializers.ArtistSerializer( [artist_credit], many=True, context={"include_ap_context": False}
artist, context={"include_ap_context": False} ).data,
).data
],
"attributedTo": actor.fid, "attributedTo": actor.fid,
"tag": [ "tag": [
{"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Punk"},
@ -850,7 +868,7 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
assert album.title == payload["name"] assert album.title == payload["name"]
assert str(album.mbid) == payload["musicbrainzId"] assert str(album.mbid) == payload["musicbrainzId"]
assert album.release_date == released assert album.release_date == released
assert album.artist == artist assert album.artist_credit.all()[0].artist == artist
assert album.attachment_cover.url == payload["image"]["href"] assert album.attachment_cover.url == payload["image"]["href"]
assert album.attachment_cover.mimetype == payload["image"]["mediaType"] assert album.attachment_cover.mimetype == payload["image"]["mediaType"]
assert sorted(album.tagged_items.values_list("tag__name", flat=True)) == [ assert sorted(album.tagged_items.values_list("tag__name", flat=True)) == [
@ -860,10 +878,12 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
def test_activity_pub_album_serializer_from_ap_create_channel_artist( def test_activity_pub_album_serializer_from_ap_create_channel_artist(
factories, faker, now factories, faker, now, mocker
): ):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
ac = factories["music.ArtistCredit"](artist=channel.artist)
released = faker.date_object() released = faker.date_object()
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
@ -872,15 +892,40 @@ def test_activity_pub_album_serializer_from_ap_create_channel_artist(
"name": faker.sentence(), "name": faker.sentence(),
"published": now.isoformat(), "published": now.isoformat(),
"released": released.isoformat(), "released": released.isoformat(),
"artists": [{"type": channel.actor.type, "id": channel.actor.fid}], "artist_credit": [
{
"artist": {
"type": "Artist",
"mediaType": "text/plain",
"content": "Artist summary",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": now.isoformat(),
"attributedTo": "https://cover.image/album-artist.pn",
"tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}],
"image": {
"type": "Image",
"url": "https://cover.image/album-artist.png",
"mediaType": "image/png",
},
},
"joinphrase": "",
"name": "John Smith",
"published": now.isoformat(),
"id": "http://hello.artistcredit",
}
],
"attributedTo": actor.fid, "attributedTo": actor.fid,
} }
mocker.patch.object(utils, "retrieve_ap_object", return_value=ac)
serializer = serializers.AlbumSerializer(data=payload) serializer = serializers.AlbumSerializer(data=payload)
assert serializer.is_valid(raise_exception=True) is True assert serializer.is_valid(raise_exception=True) is True
album = serializer.save() album = serializer.save()
assert album.artist == channel.artist assert album.artist_credit.all()[0].artist == channel.artist
def test_activity_pub_album_serializer_from_ap_update(factories, faker): def test_activity_pub_album_serializer_from_ap_update(factories, faker):
@ -895,11 +940,11 @@ def test_activity_pub_album_serializer_from_ap_update(factories, faker):
"musicbrainzId": faker.uuid4(), "musicbrainzId": faker.uuid4(),
"published": album.creation_date.isoformat(), "published": album.creation_date.isoformat(),
"released": released.isoformat(), "released": released.isoformat(),
"artists": [ "artist_credit": serializers.ArtistCreditSerializer(
serializers.ArtistSerializer( album.artist_credit.all(),
album.artist, context={"include_ap_context": False} many=True,
).data context={"include_ap_context": False},
], ).data,
"attributedTo": album.attributed_to.fid, "attributedTo": album.attributed_to.fid,
"tag": [ "tag": [
{"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Punk"},
@ -946,11 +991,11 @@ def test_activity_pub_track_serializer_to_ap(factories):
"disc": track.disc_number, "disc": track.disc_number,
"license": track.license.conf["identifiers"][0], "license": track.license.conf["identifiers"][0],
"copyright": "test", "copyright": "test",
"artists": [ "artist_credit": serializers.ArtistCreditSerializer(
serializers.ArtistSerializer( track.artist_credit.all(),
track.artist, context={"include_ap_context": False} many=True,
).data context={"include_ap_context": False},
], ).data,
"album": serializers.AlbumSerializer( "album": serializers.AlbumSerializer(
track.album, context={"include_ap_context": False} track.album, context={"include_ap_context": False}
).data, ).data,
@ -1014,41 +1059,53 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"mediaType": "image/png", "mediaType": "image/png",
}, },
"tag": [{"type": "Hashtag", "name": "AlbumTag"}], "tag": [{"type": "Hashtag", "name": "AlbumTag"}],
"artists": [ "artist_credit": [
{ {
"type": "Artist", "artist": {
"mediaType": "text/plain", "type": "Artist",
"content": "Artist summary", "mediaType": "text/plain",
"id": "http://hello.artist", "content": "Artist summary",
"name": "John Smith", "id": "http://hello.artist",
"musicbrainzId": str(uuid.uuid4()), "name": "John Smith",
"published": published.isoformat(), "musicbrainzId": str(uuid.uuid4()),
"attributedTo": album_artist_attributed_to.fid, "published": published.isoformat(),
"tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}], "attributedTo": album_artist_attributed_to.fid,
"image": { "tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}],
"type": "Image", "image": {
"url": "https://cover.image/album-artist.png", "type": "Image",
"mediaType": "image/png", "url": "https://cover.image/album-artist.png",
"mediaType": "image/png",
},
}, },
"joinphrase": "",
"credit": "John Smith Credit",
"published": published.isoformat(),
"id": "http://hello.artistcredit",
} }
], ],
}, },
"artists": [ "artist_credit": [
{ {
"type": "Artist", "artist": {
"id": "http://hello.trackartist", "type": "Artist",
"name": "Bob Smith", "id": "http://hello.trackartist",
"mediaType": "text/plain", "name": "Bob Smith",
"content": "Other artist summary", "mediaType": "text/plain",
"musicbrainzId": str(uuid.uuid4()), "content": "Other artist summary",
"attributedTo": artist_attributed_to.fid, "musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(), "attributedTo": artist_attributed_to.fid,
"tag": [{"type": "Hashtag", "name": "ArtistTag"}], "published": published.isoformat(),
"image": { "tag": [{"type": "Hashtag", "name": "ArtistTag"}],
"type": "Image", "image": {
"url": "https://cover.image/artist.png", "type": "Image",
"mediaType": "image/png", "url": "https://cover.image/artist.png",
"mediaType": "image/png",
},
}, },
"joinphrase": "",
"credit": "Credit Name",
"published": published.isoformat(),
"id": "http://hello.artistcredit",
} }
], ],
"tag": [ "tag": [
@ -1061,8 +1118,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
track = serializer.save() track = serializer.save()
album = track.album album = track.album
artist = track.artist artist = track.artist_credit.all()[0].artist
album_artist = track.album.artist album_artist = track.album.artist_credit.all()[0].artist
assert track.from_activity == activity assert track.from_activity == activity
assert track.fid == data["id"] assert track.fid == data["id"]
@ -1090,33 +1147,55 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
assert album.description.content_type == data["album"]["mediaType"] assert album.description.content_type == data["album"]["mediaType"]
assert artist.from_activity == activity assert artist.from_activity == activity
assert artist.name == data["artists"][0]["name"] assert artist.name == data["artist_credit"][0]["artist"]["name"]
assert artist.fid == data["artists"][0]["id"] assert track.artist_credit.all()[0].credit == data["artist_credit"][0]["credit"]
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] assert (
album.artist_credit.all()[0].credit
== data["album"]["artist_credit"][0]["credit"]
)
assert artist.fid == data["artist_credit"][0]["artist"]["id"]
assert str(artist.mbid) == data["artist_credit"][0]["artist"]["musicbrainzId"]
assert artist.creation_date == published assert artist.creation_date == published
assert artist.attributed_to == artist_attributed_to assert artist.attributed_to == artist_attributed_to
assert artist.description.text == data["artists"][0]["content"] assert artist.description.text == data["artist_credit"][0]["artist"]["content"]
assert artist.description.content_type == data["artists"][0]["mediaType"]
assert artist.attachment_cover.url == data["artists"][0]["image"]["url"]
assert artist.attachment_cover.mimetype == data["artists"][0]["image"]["mediaType"]
assert album_artist.from_activity == activity
assert album_artist.name == data["album"]["artists"][0]["name"]
assert album_artist.fid == data["album"]["artists"][0]["id"]
assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"]
assert album_artist.creation_date == published
assert album_artist.attributed_to == album_artist_attributed_to
assert album_artist.description.text == data["album"]["artists"][0]["content"]
assert ( assert (
album_artist.description.content_type artist.description.content_type
== data["album"]["artists"][0]["mediaType"] == data["artist_credit"][0]["artist"]["mediaType"]
) )
assert ( assert (
album_artist.attachment_cover.url == data["album"]["artists"][0]["image"]["url"] artist.attachment_cover.url
== data["artist_credit"][0]["artist"]["image"]["url"]
)
assert (
artist.attachment_cover.mimetype
== data["artist_credit"][0]["artist"]["image"]["mediaType"]
)
assert album_artist.from_activity == activity
assert album_artist.name == data["album"]["artist_credit"][0]["artist"]["name"]
assert album_artist.fid == data["album"]["artist_credit"][0]["artist"]["id"]
assert (
str(album_artist.mbid)
== data["album"]["artist_credit"][0]["artist"]["musicbrainzId"]
)
assert album_artist.creation_date == published
assert album_artist.attributed_to == album_artist_attributed_to
assert (
album_artist.description.text
== data["album"]["artist_credit"][0]["artist"]["content"]
)
assert (
album_artist.description.content_type
== data["album"]["artist_credit"][0]["artist"]["mediaType"]
)
assert (
album_artist.attachment_cover.url
== data["album"]["artist_credit"][0]["artist"]["image"]["url"]
) )
assert ( assert (
album_artist.attachment_cover.mimetype album_artist.attachment_cover.mimetype
== data["album"]["artists"][0]["image"]["mediaType"] == data["album"]["artist_credit"][0]["artist"]["image"]["mediaType"]
) )
add_tags.assert_any_call(track, *["Hello", "World"]) add_tags.assert_any_call(track, *["Hello", "World"])
@ -1144,7 +1223,9 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker,
"content": "hello there", "content": "hello there",
"attributedTo": track_attributed_to.fid, "attributedTo": track_attributed_to.fid,
"album": serializers.AlbumSerializer(track.album).data, "album": serializers.AlbumSerializer(track.album).data,
"artists": [serializers.ArtistSerializer(track.artist).data], "artist_credit": serializers.ArtistCreditSerializer(
track.artist_credit.all(), many=True
).data,
"image": {"type": "Image", "mediaType": "image/jpeg", "url": faker.url()}, "image": {"type": "Image", "mediaType": "image/jpeg", "url": faker.url()},
"tag": [ "tag": [
{"type": "Hashtag", "name": "#Hello"}, {"type": "Hashtag", "name": "#Hello"},
@ -1213,23 +1294,35 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock):
"href": "https://cover.image/test.png", "href": "https://cover.image/test.png",
"mediaType": "image/png", "mediaType": "image/png",
}, },
"artists": [ "artist_credit": [
{ {
"type": "Artist", "artist": {
"id": "http://hello.artist", "type": "Artist",
"name": "John Smith", "id": "http://hello.artist",
"musicbrainzId": str(uuid.uuid4()), "name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
},
"joinphrase": "",
"credit": "John Smith Credit",
"published": published.isoformat(), "published": published.isoformat(),
"id": "http://hello.artistcredit",
} }
], ],
}, },
"artists": [ "artist_credit": [
{ {
"type": "Artist", "artist": {
"id": "http://hello.trackartist", "type": "Artist",
"name": "Bob Smith", "id": "http://hello.trackartist",
"musicbrainzId": str(uuid.uuid4()), "name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
},
"joinphrase": "",
"credit": "Bob Smith Credit",
"published": published.isoformat(), "published": published.isoformat(),
"id": "http://hello.artistcredit",
} }
], ],
}, },
@ -1259,7 +1352,6 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock):
def test_activity_pub_upload_serializer_from_ap_update(factories, mocker, now, r_mock): def test_activity_pub_upload_serializer_from_ap_update(factories, mocker, now, r_mock):
library = factories["music.Library"]() library = factories["music.Library"]()
upload = factories["music.Upload"](library=library, track__album__with_cover=True) upload = factories["music.Upload"](library=library, track__album__with_cover=True)
data = { data = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
"type": "Audio", "type": "Audio",
@ -1631,7 +1723,7 @@ def test_channel_actor_outbox_serializer(factories):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
uploads = factories["music.Upload"].create_batch( uploads = factories["music.Upload"].create_batch(
5, 5,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
library=channel.library, library=channel.library,
import_status="finished", import_status="finished",
) )
@ -1671,7 +1763,8 @@ def test_channel_upload_serializer(factories):
track__license="cc0-1.0", track__license="cc0-1.0",
track__copyright="Copyright something", track__copyright="Copyright something",
track__album__set_tags=["Rock"], track__album__set_tags=["Rock"],
track__artist__set_tags=["Indie"], track__artist_credit__artist__set_tags=["Indie"],
track__artist_credit__artist__local=True,
) )
expected = { expected = {
@ -1722,9 +1815,9 @@ def test_channel_upload_serializer(factories):
assert serializer.data == expected assert serializer.data == expected
def test_channel_upload_serializer_from_ap_create(factories, now): def test_channel_upload_serializer_from_ap_create(factories, now, mocker):
channel = factories["audio.Channel"](library__privacy_level="everyone") channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
"type": "Audio", "type": "Audio",
@ -1767,6 +1860,7 @@ def test_channel_upload_serializer_from_ap_create(factories, now):
"url": "https://image.example/image.png", "url": "https://image.example/image.png",
}, },
} }
mocker.patch.object(utils, "retrieve_ap_object", return_value=album)
serializer = serializers.ChannelUploadSerializer( serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel} data=payload, context={"channel": channel}
@ -1784,7 +1878,7 @@ def test_channel_upload_serializer_from_ap_create(factories, now):
assert upload.size == payload["url"][1]["size"] assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"] assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"] assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist assert upload.track.artist_credit.all()[0].artist == channel.artist
assert upload.track.position == payload["position"] assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"] assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to assert upload.track.attributed_to == channel.attributed_to
@ -1801,10 +1895,12 @@ def test_channel_upload_serializer_from_ap_create(factories, now):
assert upload.track.album == album assert upload.track.album == album
def test_channel_upload_serializer_from_ap_update(factories, now): def test_channel_upload_serializer_from_ap_update(factories, now, mocker):
channel = factories["audio.Channel"](library__privacy_level="everyone") channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
upload = factories["music.Upload"](track__album=album, track__artist=channel.artist) upload = factories["music.Upload"](
track__album=album, track__artist_credit__artist=channel.artist
)
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
@ -1847,6 +1943,7 @@ def test_channel_upload_serializer_from_ap_update(factories, now):
"url": "https://image.example/image.png", "url": "https://image.example/image.png",
}, },
} }
mocker.patch.object(utils, "retrieve_ap_object", return_value=album)
serializer = serializers.ChannelUploadSerializer( serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel} data=payload, context={"channel": channel}
@ -1865,7 +1962,7 @@ def test_channel_upload_serializer_from_ap_update(factories, now):
assert upload.size == payload["url"][1]["size"] assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"] assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"] assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist assert upload.track.artist_credit.all()[0].artist == channel.artist
assert upload.track.position == payload["position"] assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"] assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to assert upload.track.attributed_to == channel.attributed_to
@ -1944,3 +2041,23 @@ def test_report_serializer_to_ap(factories):
} }
serializer = serializers.FlagSerializer(report) serializer = serializers.FlagSerializer(report)
assert serializer.data == expected assert serializer.data == expected
def test_artist_credit_serializer_to_ap(factories):
ac = factories["music.ArtistCredit"](artist__local=True)
serializer = serializers.ArtistCreditSerializer(ac)
expected = {
"@context": jsonld.get_default_context(),
"id": ac.get_federation_id(),
"type": "ArtistCredit",
"artist": serializers.ArtistSerializer(
ac.artist, context={"include_ap_context": False}
).data,
"joinphrase": ac.joinphrase,
"credit": ac.credit,
"index": ac.index,
"published": ac.creation_date.isoformat(),
}
assert serializer.data == expected

View File

@ -4,7 +4,9 @@ from funkwhale_api.history import filters, models
def test_listening_filter_track_artist(factories, mocker, queryset_equal_list): def test_listening_filter_track_artist(factories, mocker, queryset_equal_list):
factories["history.Listening"]() factories["history.Listening"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_listening = factories["history.Listening"](track__artist=cf.target_artist) hidden_listening = factories["history.Listening"](
track__artist_credit__artist=cf.target_artist
)
qs = models.Listening.objects.all() qs = models.Listening.objects.all()
filterset = filters.ListeningFilter( filterset = filters.ListeningFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
@ -17,7 +19,7 @@ def test_listening_filter_track_album_artist(factories, mocker, queryset_equal_l
factories["history.Listening"]() factories["history.Listening"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_listening = factories["history.Listening"]( hidden_listening = factories["history.Listening"](
track__album__artist=cf.target_artist track__album__artist_credit__artist=cf.target_artist
) )
qs = models.Listening.objects.all() qs = models.Listening.objects.all()
filterset = filters.ListeningFilter( filterset = filters.ListeningFilter(

View File

@ -385,7 +385,13 @@ def test_manage_album_serializer(factories, now, to_api_date):
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"release_date": album.release_date.isoformat(), "release_date": album.release_date.isoformat(),
"cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
"artist": serializers.ManageNestedArtistSerializer(album.artist).data, "artist_credit": [
{
"artist": serializers.ManageNestedArtistSerializer(
album.artist_credit.all()[0].artist
).data
}
],
"attributed_to": serializers.ManageBaseActorSerializer( "attributed_to": serializers.ManageBaseActorSerializer(
album.attributed_to album.attributed_to
).data, ).data,
@ -412,7 +418,13 @@ def test_manage_track_serializer(factories, now, to_api_date):
"copyright": track.copyright, "copyright": track.copyright,
"license": track.license, "license": track.license,
"creation_date": to_api_date(track.creation_date), "creation_date": to_api_date(track.creation_date),
"artist": serializers.ManageNestedArtistSerializer(track.artist).data, "artist_credit": [
{
"artist": serializers.ManageNestedArtistSerializer(
track.artist_credit.all()[0].artist
).data
}
],
"album": serializers.ManageTrackAlbumSerializer(track.album).data, "album": serializers.ManageTrackAlbumSerializer(track.album).data,
"attributed_to": serializers.ManageBaseActorSerializer( "attributed_to": serializers.ManageBaseActorSerializer(
track.attributed_to track.attributed_to

View File

@ -216,7 +216,9 @@ def test_album_list(factories, superuser_api_client, settings):
album = factories["music.Album"]() album = factories["music.Album"]()
factories["music.Album"]() factories["music.Album"]()
url = reverse("api:v1:manage:library:albums-list") url = reverse("api:v1:manage:library:albums-list")
response = superuser_api_client.get(url, {"q": f'artist:"{album.artist.name}"'}) response = superuser_api_client.get(
url, {"q": f'artist:"{album.artist_credit.all()[0].artist.name}"'}
)
assert response.status_code == 200 assert response.status_code == 200

View File

@ -33,7 +33,7 @@ def test_album_filter_hidden(factories, mocker, queryset_equal_list):
factories["music.Album"]() factories["music.Album"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_album = factories["music.Album"](artist=cf.target_artist) hidden_album = factories["music.Album"](artist_credit__artist=cf.target_artist)
qs = models.Album.objects.all() qs = models.Album.objects.all()
filterset = filters.AlbumFilter( filterset = filters.AlbumFilter(
@ -59,7 +59,7 @@ def test_artist_filter_hidden(factories, mocker, queryset_equal_list):
def test_artist_filter_track_artist(factories, mocker, queryset_equal_list): def test_artist_filter_track_artist(factories, mocker, queryset_equal_list):
factories["music.Track"]() factories["music.Track"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_track = factories["music.Track"](artist=cf.target_artist) hidden_track = factories["music.Track"](artist_credit__artist=cf.target_artist)
qs = models.Track.objects.all() qs = models.Track.objects.all()
filterset = filters.TrackFilter( filterset = filters.TrackFilter(
@ -72,7 +72,9 @@ def test_artist_filter_track_artist(factories, mocker, queryset_equal_list):
def test_artist_filter_track_album_artist(factories, mocker, queryset_equal_list): def test_artist_filter_track_album_artist(factories, mocker, queryset_equal_list):
factories["music.Track"]() factories["music.Track"]()
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
hidden_track = factories["music.Track"](album__artist=cf.target_artist) hidden_track = factories["music.Track"](
album__artist_credit__artist=cf.target_artist
)
qs = models.Track.objects.all() qs = models.Track.objects.all()
filterset = filters.TrackFilter( filterset = filters.TrackFilter(
@ -141,7 +143,9 @@ def test_track_filter_tag_multiple(
def test_channel_filter_track(factories, queryset_equal_list, mocker, anonymous_user): def test_channel_filter_track(factories, queryset_equal_list, mocker, anonymous_user):
channel = factories["audio.Channel"](library__privacy_level="everyone") channel = factories["audio.Channel"](library__privacy_level="everyone")
upload = factories["music.Upload"]( upload = factories["music.Upload"](
library=channel.library, playable=True, track__artist=channel.artist library=channel.library,
playable=True,
track__artist_credit__artist=channel.artist,
) )
factories["music.Track"]() factories["music.Track"]()
qs = upload.track.__class__.objects.all() qs = upload.track.__class__.objects.all()
@ -157,7 +161,9 @@ def test_channel_filter_track(factories, queryset_equal_list, mocker, anonymous_
def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_user): def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_user):
channel = factories["audio.Channel"](library__privacy_level="everyone") channel = factories["audio.Channel"](library__privacy_level="everyone")
upload = factories["music.Upload"]( upload = factories["music.Upload"](
library=channel.library, playable=True, track__artist=channel.artist library=channel.library,
playable=True,
track__artist_credit__artist=channel.artist,
) )
factories["music.Album"]() factories["music.Album"]()
qs = upload.track.album.__class__.objects.all() qs = upload.track.album.__class__.objects.all()
@ -202,14 +208,14 @@ def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous
library = factories["music.Library"](privacy_level="everyone") library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True) upload = factories["music.Upload"](library=library, playable=True)
factories["music.Artist"]() factories["music.Artist"]()
qs = upload.track.artist.__class__.objects.all()
qs = models.Artist.objects.all()
filterset = filters.ArtistFilter( filterset = filters.ArtistFilter(
{"library": library.uuid}, {"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None), request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs, queryset=qs,
) )
assert filterset.qs == [upload.track.artist_credit.all()[0].artist]
assert filterset.qs == [upload.track.artist]
def test_track_filter_artist_includes_album_artist( def test_track_filter_artist_includes_album_artist(
@ -218,12 +224,13 @@ def test_track_filter_artist_includes_album_artist(
factories["music.Track"]() factories["music.Track"]()
track1 = factories["music.Track"]() track1 = factories["music.Track"]()
track2 = factories["music.Track"]( track2 = factories["music.Track"](
album__artist=track1.artist, artist=factories["music.Artist"]() album__artist_credit__artist=track1.artist_credit.all()[0].artist,
artist_credit__artist=factories["music.Artist"](),
) )
qs = models.Track.objects.all() qs = models.Track.objects.all()
filterset = filters.TrackFilter( filterset = filters.TrackFilter(
{"artist": track1.artist.pk}, {"artist": track1.artist_credit.all()[0].artist.pk},
request=mocker.Mock(user=anonymous_user), request=mocker.Mock(user=anonymous_user),
queryset=qs, queryset=qs,
) )

View File

@ -312,23 +312,29 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.mp3", "test.mp3",
{ {
"title": "Bend", "title": "Bend",
"artists": [ "artist_credit": [
{ {
"name": "Binärpilot", "credit": "Binärpilot",
"mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), "mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
"joinphrase": "; ",
},
{
"credit": "Another artist",
"mbid": None,
"joinphrase": "",
}, },
{"name": "Another artist", "mbid": None},
], ],
"album": { "album": {
"title": "You Can't Stop Da Funk", "title": "You Can't Stop Da Funk",
"mbid": uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124"), "mbid": uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124"),
"release_date": datetime.date(2006, 2, 7), "release_date": datetime.date(2006, 2, 7),
"artists": [ "artist_credit": [
{ {
"name": "Binärpilot", "credit": "Binärpilot",
"mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), "mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
"joinphrase": "; ",
}, },
{"name": "Another artist", "mbid": None}, {"credit": "Another artist", "mbid": None, "joinphrase": ""},
], ],
}, },
"position": 2, "position": 2,
@ -344,25 +350,32 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.ogg", "test.ogg",
{ {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning", "title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "; ",
},
{
"credit": "Musopen Symphony Orchestra",
"mbid": None,
"joinphrase": "",
}, },
{"name": "Musopen Symphony Orchestra", "mbid": None},
], ],
"album": { "album": {
"title": "Peer Gynt Suite no. 1, op. 46", "title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), "mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15), "release_date": datetime.date(2012, 8, 15),
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "; ",
}, },
{ {
"name": "Musopen Symphony Orchestra", "credit": "Musopen Symphony Orchestra",
"mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"), "mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"),
"joinphrase": "",
}, },
], ],
}, },
@ -379,25 +392,32 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.opus", "test.opus",
{ {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning", "title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "; ",
},
{
"credit": "Musopen Symphony Orchestra",
"mbid": None,
"joinphrase": "",
}, },
{"name": "Musopen Symphony Orchestra", "mbid": None},
], ],
"album": { "album": {
"title": "Peer Gynt Suite no. 1, op. 46", "title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), "mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15), "release_date": datetime.date(2012, 8, 15),
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "; ",
}, },
{ {
"name": "Musopen Symphony Orchestra", "credit": "Musopen Symphony Orchestra",
"mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"), "mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"),
"joinphrase": "",
}, },
], ],
}, },
@ -414,20 +434,22 @@ def test_metadata_fallback_ogg_theora(mocker):
"test_theora.ogg", "test_theora.ogg",
{ {
"title": "Drei Kreuze (dass wir hier sind)", "title": "Drei Kreuze (dass wir hier sind)",
"artists": [ "artist_credit": [
{ {
"name": "Die Toten Hosen", "credit": "Die Toten Hosen",
"mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), "mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
"joinphrase": "",
} }
], ],
"album": { "album": {
"title": "Ballast der Republik", "title": "Ballast der Republik",
"mbid": uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf"), "mbid": uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf"),
"release_date": datetime.date(2012, 5, 4), "release_date": datetime.date(2012, 5, 4),
"artists": [ "artist_credit": [
{ {
"name": "Die Toten Hosen", "credit": "Die Toten Hosen",
"mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), "mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"),
"joinphrase": "",
} }
], ],
}, },
@ -446,20 +468,22 @@ def test_metadata_fallback_ogg_theora(mocker):
"sample.flac", "sample.flac",
{ {
"title": "999,999", "title": "999,999",
"artists": [ "artist_credit": [
{ {
"name": "Nine Inch Nails", "credit": "Nine Inch Nails",
"mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), "mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
"joinphrase": "",
} }
], ],
"album": { "album": {
"title": "The Slip", "title": "The Slip",
"mbid": uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1"), "mbid": uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1"),
"release_date": datetime.date(2008, 5, 5), "release_date": datetime.date(2008, 5, 5),
"artists": [ "artist_credit": [
{ {
"name": "Nine Inch Nails", "credit": "Nine Inch Nails",
"mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), "mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
"joinphrase": "",
} }
], ],
}, },
@ -497,12 +521,14 @@ def test_track_metadata_serializer(path, expected, mocker):
}, },
[ [
{ {
"name": "Hello", "credit": "Hello",
"mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"),
"joinphrase": "; ",
}, },
{ {
"name": "World", "credit": "World",
"mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"), "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"),
"joinphrase": "",
}, },
], ],
), ),
@ -513,14 +539,16 @@ def test_track_metadata_serializer(path, expected, mocker):
}, },
[ [
{ {
"name": "Hello", "credit": "Hello",
"mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"),
"joinphrase": "; ",
}, },
{ {
"name": "World", "credit": "World",
"mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"), "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"),
"joinphrase": "; ",
}, },
{"name": "Foo", "mbid": None}, {"credit": "Foo", "mbid": None, "joinphrase": ""},
], ],
), ),
], ],
@ -533,8 +561,8 @@ def test_artists_cleaning(raw, expected):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"data, errored_field", "data, errored_field",
[ [
({"name": "Hello", "mbid": "wrong-uuid"}, "mbid"), # wrong uuid ({"credit": "Hello", "mbid": "wrong-uuid"}, "mbid"), # wrong uuid
({"name": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, "name"), ({"credit": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, "credit"),
], ],
) )
def test_artist_serializer_validation(data, errored_field): def test_artist_serializer_validation(data, errored_field):
@ -584,24 +612,27 @@ def test_fake_metadata_with_serializer():
expected = { expected = {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning", "title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"description": {"text": "hello there", "content_type": "text/plain"}, "description": {"text": "hello there", "content_type": "text/plain"},
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "",
} }
], ],
"album": { "album": {
"title": "Peer Gynt Suite no. 1, op. 46", "title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), "mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15), "release_date": datetime.date(2012, 8, 15),
"artists": [ "artist_credit": [
{ {
"name": "Edvard Grieg", "credit": "Edvard Grieg",
"mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"),
"joinphrase": "; ",
}, },
{ {
"name": "Musopen Symphony Orchestra", "credit": "Musopen Symphony Orchestra",
"mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"), "mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"),
"joinphrase": "",
}, },
], ],
"cover_data": None, "cover_data": None,
@ -614,6 +645,7 @@ def test_fake_metadata_with_serializer():
} }
serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data)) serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data))
assert serializer.is_valid(raise_exception=True) is True assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == expected assert serializer.validated_data == expected
@ -626,12 +658,12 @@ def test_serializer_album_artist_missing():
expected = { expected = {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning", "title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"artists": [{"name": "Edvard Grieg", "mbid": None}], "artist_credit": [{"credit": "Edvard Grieg", "mbid": None, "joinphrase": ""}],
"album": { "album": {
"title": "Peer Gynt Suite no. 1, op. 46", "title": "Peer Gynt Suite no. 1, op. 46",
"mbid": None, "mbid": None,
"release_date": None, "release_date": None,
"artists": [], "artist_credit": [],
"cover_data": None, "cover_data": None,
}, },
} }
@ -654,12 +686,12 @@ def test_serializer_album_artist_missing():
def test_serializer_album_default_title_when_missing_or_empty(data): def test_serializer_album_default_title_when_missing_or_empty(data):
expected = { expected = {
"title": "Track", "title": "Track",
"artists": [{"name": "Artist", "mbid": None}], "artist_credit": [{"credit": "Artist", "mbid": None, "joinphrase": ""}],
"album": { "album": {
"title": metadata.UNKNOWN_ALBUM, "title": metadata.UNKNOWN_ALBUM,
"mbid": None, "mbid": None,
"release_date": None, "release_date": None,
"artists": [], "artist_credit": [],
"cover_data": None, "cover_data": None,
}, },
} }
@ -681,12 +713,12 @@ def test_serializer_empty_fields(field_name):
} }
expected = { expected = {
"title": "Track Title", "title": "Track Title",
"artists": [{"name": "Track Artist", "mbid": None}], "artist_credit": [{"credit": "Track Artist", "mbid": None, "joinphrase": ""}],
"album": { "album": {
"title": "Track Album", "title": "Track Album",
"mbid": None, "mbid": None,
"release_date": None, "release_date": None,
"artists": [], "artist_credit": [],
"cover_data": None, "cover_data": None,
}, },
} }
@ -698,12 +730,12 @@ def test_serializer_empty_fields(field_name):
def test_serializer_strict_mode_false(): def test_serializer_strict_mode_false():
data = {} data = {}
expected = { expected = {
"artists": [{"name": None, "mbid": None}], "artist_credit": [],
"album": { "album": {
"title": "[Unknown Album]", "title": "[Unknown Album]",
"mbid": None, "mbid": None,
"release_date": None, "release_date": None,
"artists": [], "artist_credit": [],
"cover_data": None, "cover_data": None,
}, },
} }
@ -730,11 +762,21 @@ def test_artist_field_featuring():
"musicbrainz_artistid": "9a3bf45c-347d-4630-894d-7cf3e8e0b632/cbf9738d-8f81-4a92-bc64-ede09341652d", "musicbrainz_artistid": "9a3bf45c-347d-4630-894d-7cf3e8e0b632/cbf9738d-8f81-4a92-bc64-ede09341652d",
} }
expected = [{"name": "Santana feat. Chris Cornell", "mbid": None}] expected = [
{
"credit": "Santana",
"mbid": uuid.UUID("9a3bf45c-347d-4630-894d-7cf3e8e0b632"),
"joinphrase": " feat. ",
},
{
"credit": "Chris Cornell",
"mbid": uuid.UUID("cbf9738d-8f81-4a92-bc64-ede09341652d"),
"joinphrase": "",
},
]
field = metadata.ArtistField() field = metadata.ArtistField()
value = field.get_value(data) value = field.get_value(data)
assert field.to_internal_value(value) == expected assert field.to_internal_value(value) == expected
@ -756,23 +798,23 @@ def test_acquire_tags_from_genre(genre, expected_tags):
data = { data = {
"title": "Track Title", "title": "Track Title",
"artist": "Track Artist", "artist": "Track Artist",
# "artist_credit": [{"credit": "Track Artist"}],
"album": "Track Album", "album": "Track Album",
"genre": genre, "genre": genre,
} }
expected = { expected = {
"title": "Track Title", "title": "Track Title",
"artists": [{"name": "Track Artist", "mbid": None}], "artist_credit": [{"credit": "Track Artist", "mbid": None, "joinphrase": ""}],
"album": { "album": {
"title": "Track Album", "title": "Track Album",
"mbid": None, "mbid": None,
"release_date": None, "release_date": None,
"artists": [], "artist_credit": [],
"cover_data": None, "cover_data": None,
}, },
} }
if expected_tags: if expected_tags:
expected["tags"] = expected_tags expected["tags"] = expected_tags
serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data)) serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data))
assert serializer.is_valid(raise_exception=True) is True assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == expected assert serializer.validated_data == expected

View File

@ -27,6 +27,48 @@
# upload_manager = new_apps.get_model(a, "Upload") # upload_manager = new_apps.get_model(a, "Upload")
# for upload in mapping_list: # for upload in mapping_list:
# upload_obj = upload_manager.objects.get(pk=upload[4]) # upload_obj = upload_manager.objects.get(pk=upload[4])
# assert upload_obj.quality == upload[3] # assert upload_obj.quality == upload[3]
def test_artist_credit_migration(migrator):
mapping_list = [("artist_name", "album_title", "track_title")]
a, f, t = (
"music",
"0058_upload_quality",
"0059_remove_album_artist_remove_track_artist_artistcredit_and_more",
)
migrator.migrate([(a, f)])
old_apps = migrator.loader.project_state([(a, f)]).apps
Track = old_apps.get_model(a, "Track")
Album = old_apps.get_model(a, "Album")
Artist = old_apps.get_model(a, "Artist")
for track in mapping_list:
artist = Artist.objects.create(name=track[0])
old_album = Album.objects.create(title=track[1], artist=artist)
old_track = Track.objects.create(title=track[2], artist=artist, album=old_album)
migrator.loader.build_graph()
migrator.migrate([(a, t)])
new_apps = migrator.loader.project_state([(a, t)]).apps
track_manager = new_apps.get_model(a, "Track")
album_manager = new_apps.get_model(a, "Album")
for track in mapping_list:
track_obj = track_manager.objects.get(title=track[2])
album_obj = album_manager.objects.get(title=track[1])
assert track_obj.artist_credit.all()[0].artist.pk == old_track.artist.pk
assert track_obj.artist_credit.all()[0].joinphrase == ""
assert track_obj.artist_credit.all()[0].credit == old_track.artist.name
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

View File

@ -43,7 +43,7 @@ def test_import_album_stores_release_group(factories):
album = importers.load(models.Album, cleaned_data, album_data, import_hooks=[]) album = importers.load(models.Album, cleaned_data, album_data, import_hooks=[])
assert album.release_group_id == album_data["release-group"]["id"] assert album.release_group_id == album_data["release-group"]["id"]
assert album.artist == artist assert album.artist_credit.all()[0].artist == artist
def test_import_track_from_release(factories, mocker): def test_import_track_from_release(factories, mocker):
@ -91,8 +91,72 @@ def test_import_track_from_release(factories, mocker):
mocked_get.assert_called_once_with(album.mbid, includes=models.Album.api_includes) mocked_get.assert_called_once_with(album.mbid, includes=models.Album.api_includes)
assert track.title == track_data["recording"]["title"] assert track.title == track_data["recording"]["title"]
assert track.mbid == track_data["recording"]["id"] assert track.mbid == track_data["recording"]["id"]
assert track.album == album assert track.artist_credit.all()[0].albums.all()[0] == album
assert track.artist == artist assert track.artist_credit.all()[0].artist == artist
assert track.position == int(track_data["position"])
def test_import_track_from_multi_artist_credit_release(factories, mocker):
album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e")
artist = factories["music.Artist"](mbid="a5211c65-2465-406b-93ec-213588869dc1")
artist2 = factories["music.Artist"](mbid="a5211c65-2465-406b-93ec-21358ee69dc1")
album_data = {
"release": {
"id": album.mbid,
"title": "Daydream Nation",
"status": "Official",
"medium-count": 1,
"medium-list": [
{
"position": "1",
"format": "CD",
"track-list": [
{
"id": "03baca8b-855a-3c05-8f3d-d3235287d84d",
"position": "4",
"number": "4",
"length": "417973",
"recording": {
"id": "2109e376-132b-40ad-b993-2bb6812e19d4",
"title": "Teen Age Riot",
"length": "417973",
"artist-credit": [
{
"joinphrase": "feat",
"artist": {
"id": artist.mbid,
"name": artist.name,
},
},
{
"joinphrase": "",
"artist": {
"id": artist2.mbid,
"name": artist2.name,
},
},
],
},
"track_or_recording_length": "417973",
}
],
"track-count": 1,
}
],
}
}
mocked_get = mocker.patch(
"funkwhale_api.musicbrainz.api.releases.get", return_value=album_data
)
track_data = album_data["release"]["medium-list"][0]["track-list"][0]
track = models.Track.get_or_create_from_release(
"430347cb-0879-3113-9fde-c75b658c298e", track_data["recording"]["id"]
)[0]
mocked_get.assert_called_once_with(album.mbid, includes=models.Album.api_includes)
assert track.title == track_data["recording"]["title"]
assert track.mbid == track_data["recording"]["id"]
assert track.artist_credit.all()[0].albums.all()[0] == album
assert [ac.artist for ac in track.artist_credit.all()] == [artist, artist2]
assert track.position == int(track_data["position"]) assert track.position == int(track_data["position"])
@ -144,12 +208,11 @@ def test_import_track_with_different_artist_than_release(factories, mocker):
mocker.patch( mocker.patch(
"funkwhale_api.musicbrainz.api.recordings.get", return_value=recording_data "funkwhale_api.musicbrainz.api.recordings.get", return_value=recording_data
) )
track = models.Track.get_or_create_from_api(recording_data["recording"]["id"])[0] track = models.Track.get_or_create_from_api(recording_data["recording"]["id"])[0]
assert track.title == recording_data["recording"]["title"] assert track.title == recording_data["recording"]["title"]
assert track.mbid == recording_data["recording"]["id"] assert track.mbid == recording_data["recording"]["id"]
assert track.album == album assert track.album == album
assert track.artist == artist assert track.artist_credit.all()[0].artist == artist
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -391,7 +454,7 @@ def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
queryset = models.Artist.objects.playable_by( queryset = models.Artist.objects.playable_by(
upload.library.actor upload.library.actor
).annotate_playable_by_actor(upload.library.actor) ).annotate_playable_by_actor(upload.library.actor)
match = upload.track.artist in list(queryset) match = [ac.artist for ac in upload.track.artist_credit.all()][0] == queryset.get()
assert match is expected assert match is expected
if expected: if expected:
assert bool(queryset.first().is_playable_by_actor) is expected assert bool(queryset.first().is_playable_by_actor) is expected
@ -410,7 +473,7 @@ def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
queryset = models.Artist.objects.playable_by( queryset = models.Artist.objects.playable_by(
instance_actor instance_actor
).annotate_playable_by_actor(instance_actor) ).annotate_playable_by_actor(instance_actor)
match = upload.track.artist in list(queryset) match = [ac.artist for ac in upload.track.artist_credit.all()][0] in queryset
assert match is expected assert match is expected
if expected: if expected:
assert bool(queryset.first().is_playable_by_actor) is expected assert bool(queryset.first().is_playable_by_actor) is expected
@ -426,7 +489,7 @@ def test_artist_playable_by_anonymous(privacy_level, expected, factories):
library__local=True, library__local=True,
) )
queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None) queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None)
match = upload.track.artist in list(queryset) match = [ac.artist for ac in upload.track.artist_credit.all()][0] in queryset
assert match is expected assert match is expected
if expected: if expected:
assert bool(queryset.first().is_playable_by_actor) is expected assert bool(queryset.first().is_playable_by_actor) is expected

View File

@ -39,8 +39,10 @@ def test_can_create_album_from_api(artists, albums, mocker, db):
assert album.mbid, data["id"] assert album.mbid, data["id"]
assert album.title, "Hypnotize" assert album.title, "Hypnotize"
assert album.release_date, datetime.date(2005, 1, 1) assert album.release_date, datetime.date(2005, 1, 1)
assert album.artist.name, "System of a Down" assert album.artist_credit.all()[0].artist.name, "System of a Down"
assert album.artist.mbid, data["artist-credit"][0]["artist"]["id"] assert album.artist_credit.all()[0].artist.mbid, data["artist-credit"][0]["artist"][
"id"
]
assert album.fid == federation_utils.full_url( assert album.fid == federation_utils.full_url(
f"/federation/music/albums/{album.uuid}" f"/federation/music/albums/{album.uuid}"
) )
@ -64,9 +66,12 @@ def test_can_create_track_from_api(artists, albums, tracks, mocker, db):
assert int(data["ext:score"]) == 100 assert int(data["ext:score"]) == 100
assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed" assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed"
assert track.mbid == data["id"] assert track.mbid == data["id"]
assert track.artist.pk is not None assert track.artist_credit.all()[0].artist.pk is not None
assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801" assert (
assert track.artist.name == "Adhesive Wombat" str(track.artist_credit.all()[0].artist.mbid)
== "62c3befb-6366-4585-b256-809472333801"
)
assert track.artist_credit.all()[0].artist.name == "Adhesive Wombat"
assert str(track.album.mbid) == "a50d2a81-2a50-484d-9cb4-b9f6833f583e" assert str(track.album.mbid) == "a50d2a81-2a50-484d-9cb4-b9f6833f583e"
assert track.album.title == "Marsupial Madness" assert track.album.title == "Marsupial Madness"
assert track.fid == federation_utils.full_url( assert track.fid == federation_utils.full_url(
@ -114,9 +119,12 @@ def test_can_get_or_create_track_from_api(artists, albums, tracks, mocker, db):
assert int(data["ext:score"]) == 100 assert int(data["ext:score"]) == 100
assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed" assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed"
assert track.mbid == data["id"] assert track.mbid == data["id"]
assert track.artist.pk is not None assert track.artist_credit.all()[0].artist.pk is not None
assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801" assert (
assert track.artist.name == "Adhesive Wombat" str(track.artist_credit.all()[0].artist.mbid)
== "62c3befb-6366-4585-b256-809472333801"
)
assert track.artist_credit.all()[0].artist.name == "Adhesive Wombat"
track2, created = models.Track.get_or_create_from_api(mbid=data["id"]) track2, created = models.Track.get_or_create_from_api(mbid=data["id"])
assert not created assert not created

View File

@ -38,7 +38,7 @@ def test_artist_album_serializer(factories, to_api_date):
"fid": album.fid, "fid": album.fid,
"mbid": str(album.mbid), "mbid": str(album.mbid),
"title": album.title, "title": album.title,
"artist": album.artist.id, "artist_credit": [ac.id for ac in album.artist_credit.all()],
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"tracks_count": 1, "tracks_count": 1,
"is_playable": None, "is_playable": None,
@ -53,13 +53,16 @@ def test_artist_album_serializer(factories, to_api_date):
def test_artist_with_albums_serializer(factories, to_api_date): def test_artist_with_albums_serializer(factories, to_api_date):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
track = factories["music.Track"]( artist_credit = factories["music.ArtistCredit"](
album__artist__attributed_to=actor, album__artist__with_cover=True artist__attributed_to=actor, artist__with_cover=True
) )
artist = track.artist
artist = artist.__class__.objects.with_albums().get(pk=artist.pk) track = factories["music.Track"](
album = list(artist.albums.all())[0] album__artist_credit=artist_credit, artist_credit=artist_credit
setattr(artist, "_prefetched_tracks", range(42)) )
artist = track.artist_credit.all()[0].artist
album = artist.artist_credit.all()[0].albums.all()[0]
setattr(artist, "_tracks_count", 42)
expected = { expected = {
"id": artist.id, "id": artist.id,
"fid": artist.fid, "fid": artist.fid,
@ -81,12 +84,16 @@ def test_artist_with_albums_serializer(factories, to_api_date):
def test_artist_with_albums_serializer_channel(factories, to_api_date): def test_artist_with_albums_serializer_channel(factories, to_api_date):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](attributed_to=actor, artist__with_cover=True) artist = factories["music.Artist"](with_cover=True, attributed_to=actor)
track = factories["music.Track"](album__artist=channel.artist) channel = factories["audio.Channel"](attributed_to=actor, artist=artist)
artist = track.artist artist_credit = factories["music.ArtistCredit"](artist=channel.artist)
track = factories["music.Track"](
album__artist_credit=artist_credit, artist_credit=artist_credit
)
artist = track.artist_credit.all()[0].artist
artist = artist.__class__.objects.with_albums().get(pk=artist.pk) artist = artist.__class__.objects.with_albums().get(pk=artist.pk)
album = list(artist.albums.all())[0] album = list(artist.artist_credit.all()[0].albums.all())[0]
setattr(artist, "_prefetched_tracks", range(42)) setattr(artist, "_tracks_count", 42)
expected = { expected = {
"id": artist.id, "id": artist.id,
"fid": artist.fid, "fid": artist.fid,
@ -110,7 +117,9 @@ def test_artist_with_albums_serializer_channel(factories, to_api_date):
}, },
} }
serializer = serializers.ArtistWithAlbumsSerializer(artist) serializer = serializers.ArtistWithAlbumsSerializer(artist)
assert serializer.data == expected if not serializer.data == expected:
"lol"
assert serializer.data == expected
def test_upload_serializer(factories, to_api_date): def test_upload_serializer(factories, to_api_date):
@ -177,7 +186,9 @@ def test_album_serializer(factories, to_api_date):
"fid": album.fid, "fid": album.fid,
"mbid": str(album.mbid), "mbid": str(album.mbid),
"title": album.title, "title": album.title,
"artist": serializers.SimpleArtistSerializer(album.artist).data, "artist_credit": serializers.ArtistCreditSerializer(
album.artist_credit.all(), many=True
).data,
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"is_playable": False, "is_playable": False,
"duration": 0, "duration": 0,
@ -207,7 +218,9 @@ def test_track_album_serializer(factories, to_api_date):
"fid": album.fid, "fid": album.fid,
"mbid": str(album.mbid), "mbid": str(album.mbid),
"title": album.title, "title": album.title,
"artist": serializers.SimpleArtistSerializer(album.artist).data, "artist_credit": serializers.ArtistCreditSerializer(
album.artist_credit.all(), many=True
).data,
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"is_playable": False, "is_playable": False,
"cover": common_serializers.AttachmentSerializer(album.attachment_cover).data, "cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
@ -239,7 +252,9 @@ def test_track_serializer(factories, to_api_date):
expected = { expected = {
"id": track.id, "id": track.id,
"fid": track.fid, "fid": track.fid,
"artist": serializers.SimpleArtistSerializer(track.artist).data, "artist_credit": serializers.ArtistCreditSerializer(
track.artist_credit.all(), many=True
).data,
"album": serializers.TrackAlbumSerializer(track.album).data, "album": serializers.TrackAlbumSerializer(track.album).data,
"mbid": str(track.mbid), "mbid": str(track.mbid),
"title": track.title, "title": track.title,

View File

@ -41,7 +41,10 @@ def test_library_track(spa_html, no_api_auth, client, factories, settings):
"property": "music:musician", "property": "music:musician",
"content": utils.join_url( "content": utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}), utils.spa_reverse(
"library_artist",
kwargs={"pk": track.artist_credit.all()[0].artist.pk},
),
), ),
}, },
{ {
@ -117,7 +120,10 @@ def test_library_album(spa_html, no_api_auth, client, factories, settings):
"property": "music:musician", "property": "music:musician",
"content": utils.join_url( "content": utils.join_url(
settings.FUNKWHALE_URL, settings.FUNKWHALE_URL,
utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}), utils.spa_reverse(
"library_artist",
kwargs={"pk": album.artist_credit.all()[0].artist.pk},
),
), ),
}, },
{ {
@ -166,7 +172,7 @@ def test_library_album(spa_html, no_api_auth, client, factories, settings):
def test_library_artist(spa_html, no_api_auth, client, factories, settings): def test_library_artist(spa_html, no_api_auth, client, factories, settings):
album = factories["music.Album"](with_cover=True) album = factories["music.Album"](with_cover=True)
factories["music.Upload"](playable=True, track__album=album) factories["music.Upload"](playable=True, track__album=album)
artist = album.artist artist = album.artist_credit.all()[0].artist
url = f"/library/artists/{artist.pk}" url = f"/library/artists/{artist.pk}"
response = client.get(url) response = client.get(url)

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ import uuid
import magic import magic
import pytest import pytest
from django.db.models import Prefetch from django.db.models import Count, Prefetch
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -26,12 +26,12 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client):
track = factories["music.Upload"]( track = factories["music.Upload"](
library__privacy_level="everyone", library__privacy_level="everyone",
import_status="finished", import_status="finished",
track__album__artist__set_tags=tags, track__album__artist_credit__artist__set_tags=tags,
).track ).track
artist = track.artist artist = track.artist_credit.all()[0].artist
request = api_request.get("/") request = api_request.get("/")
qs = artist.__class__.objects.with_albums().prefetch_related( qs = artist.__class__.objects.with_albums().annotate(
Prefetch("tracks", to_attr="_prefetched_tracks") _tracks_count=Count("artist_credit__tracks")
) )
serializer = serializers.ArtistWithAlbumsSerializer( serializer = serializers.ArtistWithAlbumsSerializer(
qs, many=True, context={"request": request} qs, many=True, context={"request": request}
@ -39,12 +39,11 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client):
expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
for artist in serializer.data: for artist in serializer.data:
artist["tags"] = tags artist["tags"] = tags
for album in artist["albums"]:
album["is_playable"] = True
url = reverse("api:v1:artists-list") url = reverse("api:v1:artists-list")
response = logged_in_api_client.get(url) response = logged_in_api_client.get(url)
assert serializer.data[0]["tracks_count"] == 1
assert response.status_code == 200 assert response.status_code == 200
assert response.data == expected assert response.data == expected
@ -118,7 +117,9 @@ def test_artist_view_filter_playable(param, expected, factories, api_request):
"empty": factories["music.Artist"](), "empty": factories["music.Artist"](),
"full": factories["music.Upload"]( "full": factories["music.Upload"](
library__privacy_level="everyone", import_status="finished" library__privacy_level="everyone", import_status="finished"
).track.artist, )
.track.artist_credit.all()[0]
.artist,
} }
request = api_request.get("/", {"playable": param}) request = api_request.get("/", {"playable": param})
@ -817,7 +818,7 @@ def test_user_can_create_upload_in_channel(
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
url = reverse("api:v1:uploads-list") url = reverse("api:v1:uploads-list")
m = mocker.patch("funkwhale_api.common.utils.on_commit") m = mocker.patch("funkwhale_api.common.utils.on_commit")
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
response = logged_in_api_client.post( response = logged_in_api_client.post(
url, url,
{ {
@ -968,12 +969,14 @@ def test_can_get_libraries_for_music_entities(
library = upload.library library = upload.library
setattr(library, "_uploads_count", 1) setattr(library, "_uploads_count", 1)
data = { data = {
"artist": upload.track.artist, "artist": upload.track.artist_credit.all()[0].artist,
"album": upload.track.album, "album": upload.track.album,
"track": upload.track, "track": upload.track,
} }
# libraries in channel should be missing excluded # libraries in channel should be missing excluded
channel = factories["audio.Channel"](artist=upload.track.artist) channel = factories["audio.Channel"](
artist=upload.track.artist_credit.all()[0].artist
)
factories["music.Upload"]( factories["music.Upload"](
library=channel.library, playable=True, track=upload.track library=channel.library, playable=True, track=upload.track
) )
@ -1035,7 +1038,7 @@ def test_oembed_track(factories, no_api_auth, api_client, settings):
"provider_url": settings.FUNKWHALE_URL, "provider_url": settings.FUNKWHALE_URL,
"height": 150, "height": 150,
"width": 600, "width": 600,
"title": f"{track.title} by {track.artist.name}", "title": f"{track.title} by {track.artist_credit.all()[0].artist.name}",
"description": track.full_name, "description": track.full_name,
"thumbnail_url": federation_utils.full_url( "thumbnail_url": federation_utils.full_url(
track.album.attachment_cover.file.crop["200x200"].url track.album.attachment_cover.file.crop["200x200"].url
@ -1045,9 +1048,11 @@ def test_oembed_track(factories, no_api_auth, api_client, settings):
"html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format( "html": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
iframe_src iframe_src
), ),
"author_name": track.artist.name, "author_name": track.artist_credit.all()[0].artist.name,
"author_url": federation_utils.full_url( "author_url": federation_utils.full_url(
utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}) utils.spa_reverse(
"library_artist", kwargs={"pk": track.artist_credit.all()[0].artist.pk}
)
), ),
} }
@ -1071,8 +1076,8 @@ def test_oembed_album(factories, no_api_auth, api_client, settings):
"provider_url": settings.FUNKWHALE_URL, "provider_url": settings.FUNKWHALE_URL,
"height": 400, "height": 400,
"width": 600, "width": 600,
"title": f"{album.title} by {album.artist.name}", "title": f"{album.title} by {album.artist_credit.all()[0].artist.name}",
"description": f"{album.title} by {album.artist.name}", "description": f"{album.title} by {album.artist_credit.all()[0].artist.name}",
"thumbnail_url": federation_utils.full_url( "thumbnail_url": federation_utils.full_url(
album.attachment_cover.file.crop["200x200"].url album.attachment_cover.file.crop["200x200"].url
), ),
@ -1081,9 +1086,11 @@ def test_oembed_album(factories, no_api_auth, api_client, settings):
"html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format( "html": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.format(
iframe_src iframe_src
), ),
"author_name": album.artist.name, "author_name": album.artist_credit.all()[0].artist.name,
"author_url": federation_utils.full_url( "author_url": federation_utils.full_url(
utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}) utils.spa_reverse(
"library_artist", kwargs={"pk": album.artist_credit.all()[0].artist.pk}
)
), ),
} }
@ -1097,7 +1104,7 @@ def test_oembed_artist(factories, no_api_auth, api_client, settings):
settings.FUNKWHALE_EMBED_URL = "http://embed" settings.FUNKWHALE_EMBED_URL = "http://embed"
track = factories["music.Track"](album__with_cover=True) track = factories["music.Track"](album__with_cover=True)
album = track.album album = track.album
artist = track.artist artist = track.artist_credit.all()[0].artist
url = reverse("api:v1:oembed") url = reverse("api:v1:oembed")
artist_url = f"https://test.com/library/artists/{artist.pk}" artist_url = f"https://test.com/library/artists/{artist.pk}"
iframe_src = f"http://embed?type=artist&id={artist.pk}" iframe_src = f"http://embed?type=artist&id={artist.pk}"
@ -1281,7 +1288,7 @@ def test_artist_list_exclude_channels(
) )
def test_album_list_exclude_channels(params, expected, factories, logged_in_api_client): def test_album_list_exclude_channels(params, expected, factories, logged_in_api_client):
channel_artist = factories["audio.Channel"]().artist channel_artist = factories["audio.Channel"]().artist
factories["music.Album"](artist=channel_artist) factories["music.Album"](artist_credit__artist=channel_artist)
url = reverse("api:v1:albums-list") url = reverse("api:v1:albums-list")
response = logged_in_api_client.get(url, params) response = logged_in_api_client.get(url, params)
@ -1296,7 +1303,7 @@ def test_album_list_exclude_channels(params, expected, factories, logged_in_api_
) )
def test_track_list_exclude_channels(params, expected, factories, logged_in_api_client): def test_track_list_exclude_channels(params, expected, factories, logged_in_api_client):
channel_artist = factories["audio.Channel"]().artist channel_artist = factories["audio.Channel"]().artist
factories["music.Track"](artist=channel_artist) factories["music.Track"](artist_credit__artist=channel_artist)
url = reverse("api:v1:tracks-list") url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url, params) response = logged_in_api_client.get(url, params)
@ -1404,10 +1411,12 @@ def test_channel_owner_can_create_album(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor, artist__with_cover=True) channel = factories["audio.Channel"](attributed_to=actor, artist__with_cover=True)
attachment = factories["common.Attachment"](actor=actor) attachment = factories["common.Attachment"](actor=actor)
ac = factories["music.ArtistCredit"](artist=channel.artist)
url = reverse("api:v1:albums-list") url = reverse("api:v1:albums-list")
data = { data = {
"artist": channel.artist.pk, "artist_credit": ac.pk,
"cover": attachment.uuid, "cover": attachment.uuid,
"title": "Hello world", "title": "Hello world",
"release_date": "2019-01-02", "release_date": "2019-01-02",
@ -1417,10 +1426,9 @@ def test_channel_owner_can_create_album(factories, logged_in_api_client):
response = logged_in_api_client.post(url, data, format="json") response = logged_in_api_client.post(url, data, format="json")
assert response.status_code == 201 assert response.status_code == 204
album = channel.artist.albums.get(title=data["title"])
album = channel.artist.artist_credit.albums().get(title=data["title"])
assert ( assert (
response.data response.data
== serializers.AlbumSerializer(album, context={"description": True}).data == serializers.AlbumSerializer(album, context={"description": True}).data
@ -1437,7 +1445,7 @@ def test_channel_owner_can_delete_album(factories, logged_in_api_client, mocker)
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
@ -1474,7 +1482,7 @@ def test_other_user_cannot_create_album(factories, logged_in_api_client):
def test_other_user_cannot_delete_album(factories, logged_in_api_client): def test_other_user_cannot_delete_album(factories, logged_in_api_client):
logged_in_api_client.user.create_actor() logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist) album = factories["music.Album"](artist_credit__artist=channel.artist)
url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk}) url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
@ -1487,7 +1495,7 @@ def test_channel_owner_can_delete_track(factories, logged_in_api_client, mocker)
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor() actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor) channel = factories["audio.Channel"](attributed_to=actor)
track = factories["music.Track"](artist=channel.artist) track = factories["music.Track"](artist_credit__artist=channel.artist)
upload1 = factories["music.Upload"](track=track) upload1 = factories["music.Upload"](track=track)
upload2 = factories["music.Upload"](track=track) upload2 = factories["music.Upload"](track=track)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
@ -1506,7 +1514,7 @@ def test_channel_owner_can_delete_track(factories, logged_in_api_client, mocker)
def test_other_user_cannot_delete_track(factories, logged_in_api_client): def test_other_user_cannot_delete_track(factories, logged_in_api_client):
logged_in_api_client.user.create_actor() logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
track = factories["music.Track"](artist=channel.artist) track = factories["music.Track"](artist_credit__artist=channel.artist)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk}) url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
response = logged_in_api_client.delete(url) response = logged_in_api_client.delete(url)
@ -1597,3 +1605,13 @@ def test_fs_import_cancel_already_running(
assert response.status_code == 204 assert response.status_code == 204
assert cache.get("fs-import:status") == "canceled" assert cache.get("fs-import:status") == "canceled"
def test_album_create_artist_credit(factories, logged_in_api_client):
artist = factories["music.Artist"]()
factories["audio.Channel"](artist=artist)
url = reverse("api:v1:albums-list")
response = logged_in_api_client.post(
url, {"artist": artist.pk, "title": "super album"}, format="json"
)
assert response.status_code == 204

View File

@ -23,6 +23,8 @@ def test_playlist_filter_artist(factories, queryset_equal_list):
plt = factories["playlists.PlaylistTrack"]() plt = factories["playlists.PlaylistTrack"]()
factories["playlists.PlaylistTrack"]() factories["playlists.PlaylistTrack"]()
qs = models.Playlist.objects.all() qs = models.Playlist.objects.all()
filterset = filters.PlaylistFilter({"artist": plt.track.artist.pk}, queryset=qs) filterset = filters.PlaylistFilter(
{"artist": plt.track.artist_credit.all()[0].artist.pk}, queryset=qs
)
assert filterset.qs == [plt.playlist] assert filterset.qs == [plt.playlist]

View File

@ -1,3 +1,5 @@
from itertools import chain
import pytest import pytest
from django.urls import reverse from django.urls import reverse
@ -20,9 +22,11 @@ def test_can_list_config_options(logged_in_api_client):
def test_can_validate_config(logged_in_api_client, factories): def test_can_validate_config(logged_in_api_client, factories):
artist1 = factories["music.Artist"]() artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]() artist2 = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist1) factories["music.Track"].create_batch(3, artist_credit__artist=artist1)
factories["music.Track"].create_batch(3, artist=artist2) factories["music.Track"].create_batch(3, artist_credit__artist=artist2)
candidates = artist1.tracks.order_by("pk") candidates = list(
chain(*[ac.tracks.order_by("pk") for ac in artist1.artist_credit.all()])
)
f = {"filters": [{"type": "artist", "ids": [artist1.pk]}]} f = {"filters": [{"type": "artist", "ids": [artist1.pk]}]}
url = reverse("api:v1:radios:radios-validate") url = reverse("api:v1:radios:radios-validate")
response = logged_in_api_client.post(url, f, format="json") response = logged_in_api_client.post(url, f, format="json")
@ -32,7 +36,7 @@ def test_can_validate_config(logged_in_api_client, factories):
payload = response.data payload = response.data
expected = { expected = {
"count": candidates.count(), "count": len(candidates),
"sample": TrackSerializer(candidates, many=True).data, "sample": TrackSerializer(candidates, many=True).data,
} }

View File

@ -91,7 +91,9 @@ def test_can_get_choices_for_favorites_radio(factories):
def test_can_get_choices_for_custom_radio(factories): def test_can_get_choices_for_custom_radio(factories):
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
files = factories["music.Upload"].create_batch(5, track__artist=artist) files = factories["music.Upload"].create_batch(
5, track__artist_credit__artist=artist
)
tracks = [f.track for f in files] tracks = [f.track for f in files]
factories["music.Upload"].create_batch(5) factories["music.Upload"].create_batch(5)
@ -208,7 +210,9 @@ def test_can_start_artist_radio(factories):
user = factories["users.User"]() user = factories["users.User"]()
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
factories["music.Upload"].create_batch(5) factories["music.Upload"].create_batch(5)
good_files = factories["music.Upload"].create_batch(5, track__artist=artist) good_files = factories["music.Upload"].create_batch(
5, track__artist_credit__artist=artist
)
good_tracks = [f.track for f in good_files] good_tracks = [f.track for f in good_files]
radio = radios.ArtistRadio() radio = radios.ArtistRadio()
@ -224,7 +228,7 @@ def test_can_start_tag_radio(factories):
good_tracks = [ good_tracks = [
factories["music.Track"](set_tags=[tag.name]), factories["music.Track"](set_tags=[tag.name]),
factories["music.Track"](album__set_tags=[tag.name]), factories["music.Track"](album__set_tags=[tag.name]),
factories["music.Track"](album__artist__set_tags=[tag.name]), factories["music.Track"](album__artist_credit__artist__set_tags=[tag.name]),
] ]
factories["music.Track"].create_batch(3, set_tags=["notrock"]) factories["music.Track"].create_batch(3, set_tags=["notrock"])
@ -358,7 +362,7 @@ def test_session_radio_get_queryset_ignore_filtered_track_artist(
factories, queryset_equal_list factories, queryset_equal_list
): ):
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
factories["music.Track"](artist=cf.target_artist) factories["music.Track"](artist_credit__artist=cf.target_artist)
valid_track = factories["music.Track"]() valid_track = factories["music.Track"]()
radio = radios.RandomRadio() radio = radios.RandomRadio()
radio.start_session(user=cf.user) radio.start_session(user=cf.user)
@ -370,7 +374,7 @@ def test_session_radio_get_queryset_ignore_filtered_track_album_artist(
factories, queryset_equal_list factories, queryset_equal_list
): ):
cf = factories["moderation.UserFilter"](for_artist=True) cf = factories["moderation.UserFilter"](for_artist=True)
factories["music.Track"](album__artist=cf.target_artist) factories["music.Track"](album__artist_credit__artist=cf.target_artist)
valid_track = factories["music.Track"]() valid_track = factories["music.Track"]()
radio = radios.RandomRadio() radio = radios.RandomRadio()
radio.start_session(user=cf.user) radio.start_session(user=cf.user)
@ -382,9 +386,11 @@ def test_get_choices_for_custom_radio_exclude_artist(factories):
included_artist = factories["music.Artist"]() included_artist = factories["music.Artist"]()
excluded_artist = factories["music.Artist"]() excluded_artist = factories["music.Artist"]()
included_uploads = factories["music.Upload"].create_batch( included_uploads = factories["music.Upload"].create_batch(
5, track__artist=included_artist 5, track__artist_credit__artist=included_artist
)
factories["music.Upload"].create_batch(
5, track__artist_credit__artist=excluded_artist
) )
factories["music.Upload"].create_batch(5, track__artist=excluded_artist)
session = factories["radios.CustomRadioSession"]( session = factories["radios.CustomRadioSession"](
custom_radio__config=[ custom_radio__config=[
@ -422,7 +428,9 @@ def test_can_start_custom_multiple_radio_from_api(api_client, factories):
map_filters_to_type = {"tags": "names", "artists": "ids", "playlists": "names"} map_filters_to_type = {"tags": "names", "artists": "ids", "playlists": "names"}
for key, value in map_filters_to_type.items(): for key, value in map_filters_to_type.items():
attr = value[:-1] attr = value[:-1]
track_filter_key = [getattr(a.artist, attr) for a in tracks] track_filter_key = [
getattr(a.artist_credit.all()[0].artist, attr) for a in tracks
]
config = {"filters": [{"type": key, value: track_filter_key}]} config = {"filters": [{"type": key, value: track_filter_key}]}
response = api_client.post( response = api_client.post(
url, url,

View File

@ -95,7 +95,9 @@ def test_can_get_choices_for_favorites_radio_v2(factories):
def test_can_get_choices_for_custom_radio_v2(factories): def test_can_get_choices_for_custom_radio_v2(factories):
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
files = factories["music.Upload"].create_batch(5, track__artist=artist) files = factories["music.Upload"].create_batch(
5, track__artist_credit__artist=artist
)
tracks = [f.track for f in files] tracks = [f.track for f in files]
factories["music.Upload"].create_batch(5) factories["music.Upload"].create_batch(5)

View File

@ -37,7 +37,7 @@ def test_get_valid_filepart(input, expected):
[ [
( (
{ {
"artist__name": "Hello", "artist_credit__artist__name": "Hello",
"album__title": "World", "album__title": "World",
"title": "foo", "title": "foo",
"position": None, "position": None,
@ -47,7 +47,7 @@ def test_get_valid_filepart(input, expected):
), ),
( (
{ {
"artist__name": "AC/DC", "artist_credit__artist__name": "AC/DC",
"album__title": "escape/my", "album__title": "escape/my",
"title": "sla/sh", "title": "sla/sh",
"position": 12, "position": 12,
@ -76,8 +76,8 @@ def test_get_artists_serializer(factories):
name="", with_cover=False name="", with_cover=False
) # Shouldn't be serialised ) # Shouldn't be serialised
factories["music.Album"].create_batch(size=3, artist=artist1) factories["music.Album"].create_batch(size=3, artist_credit__artist=artist1)
factories["music.Album"].create_batch(size=2, artist=artist2) factories["music.Album"].create_batch(size=2, artist_credit__artist=artist2)
expected = { expected = {
"ignoredArticles": "", "ignoredArticles": "",
@ -125,7 +125,7 @@ def test_get_artists_serializer(factories):
def test_get_artist_serializer(factories): def test_get_artist_serializer(factories):
artist = factories["music.Artist"](with_cover=True) artist = factories["music.Artist"](with_cover=True)
album = factories["music.Album"](artist=artist, with_cover=True) album = factories["music.Album"](artist_credit__artist=artist, with_cover=True)
tracks = factories["music.Track"].create_batch(size=3, album=album) tracks = factories["music.Track"].create_batch(size=3, album=album)
expected = { expected = {
@ -191,7 +191,7 @@ def test_get_track_data_content_type(mimetype, extension, expected, factories):
def test_get_album_serializer(factories): def test_get_album_serializer(factories):
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
album = factories["music.Album"](artist=artist, with_cover=True) album = factories["music.Album"](artist_credit__artist=artist, with_cover=True)
track = factories["music.Track"](album=album, disc_number=42) track = factories["music.Track"](album=album, disc_number=42)
upload = factories["music.Upload"](track=track, bitrate=42000, duration=43, size=44) upload = factories["music.Upload"](track=track, bitrate=42000, duration=43, size=44)
tagged_item = factories["tags.TaggedItem"](content_object=album, tag__name="foo") tagged_item = factories["tags.TaggedItem"](content_object=album, tag__name="foo")
@ -243,8 +243,8 @@ def test_get_album_serializer(factories):
def test_starred_tracks2_serializer(factories): def test_starred_tracks2_serializer(factories):
artist = factories["music.Artist"]() artist_credit = factories["music.ArtistCredit"]()
album = factories["music.Album"](artist=artist) album = factories["music.Album"](artist_credit=artist_credit)
track = factories["music.Track"](album=album) track = factories["music.Track"](album=album)
upload = factories["music.Upload"](track=track) upload = factories["music.Upload"](track=track)
favorite = factories["favorites.TrackFavorite"](track=track) favorite = factories["favorites.TrackFavorite"](track=track)
@ -347,7 +347,7 @@ def test_channel_episode_serializer(factories):
description = factories["common.Content"]() description = factories["common.Content"]()
channel = factories["audio.Channel"]() channel = factories["audio.Channel"]()
track = factories["music.Track"]( track = factories["music.Track"](
description=description, artist=channel.artist, with_cover=True description=description, artist_credit__artist=channel.artist, with_cover=True
) )
upload = factories["music.Upload"]( upload = factories["music.Upload"](
playable=True, track=track, bitrate=128000, duration=42 playable=True, track=track, bitrate=128000, duration=42

View File

@ -152,7 +152,9 @@ def test_get_artist(
url = reverse("api:subsonic:subsonic-get_artist") url = reverse("api:subsonic:subsonic-get_artist")
assert url.endswith("getArtist") is True assert url.endswith("getArtist") is True
artist = factories["music.Artist"](playable=True) artist = factories["music.Artist"](playable=True)
factories["music.Album"].create_batch(size=3, artist=artist, playable=True) factories["music.Album"].create_batch(
size=3, artist_credit__artist=artist, playable=True
)
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by") playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
expected = {"artist": serializers.GetArtistSerializer(artist).data} expected = {"artist": serializers.GetArtistSerializer(artist).data}
@ -202,9 +204,9 @@ def test_get_album(
): ):
url = reverse("api:subsonic:subsonic-get_album") url = reverse("api:subsonic:subsonic-get_album")
assert url.endswith("getAlbum") is True assert url.endswith("getAlbum") is True
artist = factories["music.Artist"]() artist_credit = factories["music.ArtistCredit"]()
album = ( album = (
factories["music.Album"](artist=artist) factories["music.Album"](artist_credit=artist_credit)
.__class__.objects.with_duration() .__class__.objects.with_duration()
.first() .first()
) )
@ -217,7 +219,10 @@ def test_get_album(
assert response.data == expected assert response.data == expected
playable_by.assert_called_once_with( playable_by.assert_called_once_with(
music_models.Album.objects.with_duration().select_related("artist"), None music_models.Album.objects.with_duration().prefetch_related(
"artist_credit__artist"
),
None,
) )
@ -227,8 +232,8 @@ def test_get_song(
): ):
url = reverse("api:subsonic:subsonic-get_song") url = reverse("api:subsonic:subsonic-get_song")
assert url.endswith("getSong") is True assert url.endswith("getSong") is True
artist = factories["music.Artist"]() artist_credit = factories["music.ArtistCredit"]()
album = factories["music.Album"](artist=artist) album = factories["music.Album"](artist_credit=artist_credit)
track = factories["music.Track"](album=album, playable=True) track = factories["music.Track"](album=album, playable=True)
upload = factories["music.Upload"](track=track) upload = factories["music.Upload"](track=track)
playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by") playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by")
@ -508,10 +513,12 @@ def test_get_album_list2_by_genre(f, db, logged_in_api_client, factories):
url = reverse("api:subsonic:subsonic-get_album_list2") url = reverse("api:subsonic:subsonic-get_album_list2")
assert url.endswith("getAlbumList2") is True assert url.endswith("getAlbumList2") is True
album1 = factories["music.Album"]( album1 = factories["music.Album"](
artist__name="Artist1", playable=True, set_tags=["Rock"] artist_credit__artist__name="Artist1", playable=True, set_tags=["Rock"]
).__class__.objects.with_duration()[0] ).__class__.objects.with_duration()[0]
album2 = factories["music.Album"]( album2 = factories["music.Album"](
artist__name="Artist2", playable=True, artist__set_tags=["Rock"] artist_credit__artist__name="Artist2",
playable=True,
artist_credit__artist__set_tags=["Rock"],
).__class__.objects.with_duration()[1] ).__class__.objects.with_duration()[1]
factories["music.Album"](playable=True, set_tags=["Pop"]) factories["music.Album"](playable=True, set_tags=["Pop"])
response = logged_in_api_client.get( response = logged_in_api_client.get(
@ -556,7 +563,12 @@ def test_get_album_list2_by_year(params, expected, db, logged_in_api_client, fac
@pytest.mark.parametrize("f", ["json"]) @pytest.mark.parametrize("f", ["json"])
@pytest.mark.parametrize( @pytest.mark.parametrize(
"tags_field", "tags_field",
["set_tags", "artist__set_tags", "album__set_tags", "album__artist__set_tags"], [
"set_tags",
"artist_credit__artist__set_tags",
"album__set_tags",
"album__artist_credit__artist__set_tags",
],
) )
def test_get_songs_by_genre(f, tags_field, db, logged_in_api_client, factories): def test_get_songs_by_genre(f, tags_field, db, logged_in_api_client, factories):
url = reverse("api:subsonic:subsonic-get_songs_by_genre") url = reverse("api:subsonic:subsonic-get_songs_by_genre")
@ -916,20 +928,22 @@ def test_get_podcasts(logged_in_api_client, factories, mocker):
) )
upload1 = factories["music.Upload"]( upload1 = factories["music.Upload"](
playable=True, playable=True,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
library=channel.library, library=channel.library,
bitrate=128000, bitrate=128000,
duration=42, duration=42,
) )
upload2 = factories["music.Upload"]( upload2 = factories["music.Upload"](
playable=True, playable=True,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
library=channel.library, library=channel.library,
bitrate=256000, bitrate=256000,
duration=43, duration=43,
) )
factories["federation.Follow"](actor=actor, target=channel.actor, approved=True) factories["federation.Follow"](actor=actor, target=channel.actor, approved=True)
factories["music.Upload"](import_status="pending", track__artist=channel.artist) factories["music.Upload"](
import_status="pending", track__artist_credit__artist=channel.artist
)
factories["audio.Channel"](external=True) factories["audio.Channel"](external=True)
factories["federation.Follow"]() factories["federation.Follow"]()
url = reverse("api:subsonic:subsonic-get_podcasts") url = reverse("api:subsonic:subsonic-get_podcasts")
@ -953,14 +967,14 @@ def test_get_podcasts_by_id(logged_in_api_client, factories, mocker):
) )
upload1 = factories["music.Upload"]( upload1 = factories["music.Upload"](
playable=True, playable=True,
track__artist=channel1.artist, track__artist_credit__artist=channel1.artist,
library=channel1.library, library=channel1.library,
bitrate=128000, bitrate=128000,
duration=42, duration=42,
) )
factories["music.Upload"]( factories["music.Upload"](
playable=True, playable=True,
track__artist=channel2.artist, track__artist_credit__artist=channel2.artist,
library=channel2.library, library=channel2.library,
bitrate=256000, bitrate=256000,
duration=43, duration=43,
@ -983,14 +997,14 @@ def test_get_newest_podcasts(logged_in_api_client, factories, mocker):
) )
upload1 = factories["music.Upload"]( upload1 = factories["music.Upload"](
playable=True, playable=True,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
library=channel.library, library=channel.library,
bitrate=128000, bitrate=128000,
duration=42, duration=42,
) )
upload2 = factories["music.Upload"]( upload2 = factories["music.Upload"](
playable=True, playable=True,
track__artist=channel.artist, track__artist_credit__artist=channel.artist,
library=channel.library, library=channel.library,
bitrate=256000, bitrate=256000,
duration=43, duration=43,

View File

@ -6,9 +6,11 @@ def test_get_tags_from_foreign_key(factories):
rock_tag = factories["tags.Tag"](name="Rock") rock_tag = factories["tags.Tag"](name="Rock")
rap_tag = factories["tags.Tag"](name="Rap") rap_tag = factories["tags.Tag"](name="Rap")
artist = factories["music.Artist"]() artist = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist, set_tags=["rock", "rap"])
factories["music.Track"].create_batch( factories["music.Track"].create_batch(
3, artist=artist, set_tags=["rock", "rap", "techno"] 3, artist_credit__artist=artist, set_tags=["rock", "rap"]
)
factories["music.Track"].create_batch(
3, artist_credit__artist=artist, set_tags=["rock", "rap", "techno"]
) )
result = tasks.get_tags_from_foreign_key( result = tasks.get_tags_from_foreign_key(

View File

@ -11,13 +11,13 @@ def test_resolve_recordings_to_fw_track(mocker, factories):
factories["music.Track"]( factories["music.Track"](
pk=1, pk=1,
title="I Want It That Way", title="I Want It That Way",
artist=artist, artist_credit__artist=artist,
mbid="87dfa566-21c3-45ed-bc42-1d345b8563fa", mbid="87dfa566-21c3-45ed-bc42-1d345b8563fa",
) )
factories["music.Track"]( factories["music.Track"](
pk=2, pk=2,
title="I Want It That Way", title="I Want It That Way",
artist=artist, artist_credit__artist=artist,
) )
client = typesense.Client( client = typesense.Client(

View File

@ -0,0 +1 @@
Support multiples artists for tracks and albums (#1568)

View File

@ -241,12 +241,12 @@ Given the above example, Funkwhale would create the following `ArtistCredit` obj
The Funkwhale API needs to return artist credit information in a way that is easily consumed by a client. The Funkwhale API needs to return artist credit information in a way that is easily consumed by a client.
Endpoints should include a `credited_artist` filter that allows a client to return results for which artists are credited. This filter should take a list of IDs. Endpoints should include a `artist` filter that allows a client to return results for which artists are credited. This filter should take a list of IDs.
To return any albums where the artist is listed in the `artist_credit` field, you can filter by the `artist_id` field using the `credited_artist` filter: To return any albums where the artist is listed in the `artist_credit` field, you can filter by the `artist_id` field using the `artist` filter:
```text ```text
https://open.audio/api/v2/albums?credited_artist=6451,6452 https://open.audio/api/v2/albums?artist=6451,6452
``` ```
The `credit` field of the `artist_credit` object must also be searchable using a standard query: The `credit` field of the `artist_credit` object must also be searchable using a standard query:

View File

@ -32,7 +32,7 @@ const getTrackInformationText = (track: QueueTrack | undefined) => {
return null return null
} }
return `${track.title} ${track.artistName}` return `${track.title} ${track.artistCredit}`
} }
// Update title // Update title

View File

@ -130,14 +130,13 @@ const reorderTracks = async (from: number, to: number) => {
scrollToCurrent() scrollToCurrent()
} }
} }
const hideArtist = () => { const hideArtist = () => {
if (currentTrack.value.artistId !== -1) { if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
return store.dispatch('moderation/hide', { return store.dispatch('moderation/hide', {
type: 'artist', type: 'artist',
target: { target: {
id: currentTrack.value.artistId, id: currentTrack.value.artistCredit[0].artist.id,
name: currentTrack.value.artistName name: currentTrack.value.artistCredit[0].artist.name
} }
}) })
} }
@ -264,7 +263,13 @@ if (!isWebGLSupported) {
> >
<h1>{{ currentTrack.title }}</h1> <h1>{{ currentTrack.title }}</h1>
<h2> <h2>
{{ currentTrack.artistName ?? $t('components.Queue.meta.unknownArtist') }} <div
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span>
</div>
<span class="symbol hyphen middle" /> <span class="symbol hyphen middle" />
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
</h2> </h2>
@ -281,12 +286,21 @@ if (!isWebGLSupported) {
{{ currentTrack.title }} {{ currentTrack.title }}
</router-link> </router-link>
<div class="sub header ellipsis"> <div class="sub header ellipsis">
<router-link <span>
class="discrete link artist" <template
:to="{name: 'library.artists.detail', params: {id: currentTrack.artistId }}" v-for="ac in currentTrack.artistCredit"
> :key="ac.artist.id"
{{ currentTrack.artistName ?? $t('components.Queue.meta.unknownArtist') }} >
</router-link> <router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent=""
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <span class="middle slash symbol" />
<router-link <router-link

View File

@ -2,6 +2,7 @@
import type { QueueItemSource } from '~/types' import type { QueueItemSource } from '~/types'
import time from '~/utils/time' import time from '~/utils/time'
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
interface Events { interface Events {
(e: 'play', index: number): void (e: 'play', index: number): void
@ -43,7 +44,7 @@ defineProps<Props>()
> >
<strong>{{ source.title }}</strong><br> <strong>{{ source.title }}</strong><br>
<span> <span>
{{ source.artistName }} {{ generateTrackCreditStringFromQueue(source) }}
</span> </span>
</button> </button>
</div> </div>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ArtistCredit } from '~/types'
interface Props {
artistCredit: ArtistCredit[]
}
const props = defineProps<Props>()
const getRoute = (ac: ArtistCredit) => {
return {
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
params: {
id: ac.artist.id.toString()
}
}
}
</script>
<template>
<div class="artist-label ui image label">
<template
v-for="ac in props.artistCredit"
:key="ac.artist.id"
>
<router-link
:to="getRoute(ac)"
>
<img
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)"
alt=""
:class="[{circular: ac.artist.content_category != 'podcast'}]"
>
<i
v-else-if="ac.index === 0"
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
/>
{{ ac.credit }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</div>
</template>

View File

@ -43,7 +43,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
> >
<img <img
v-else-if="entry.artist?.content_category === 'podcast' && defaultCover != undefined" v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)" v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
class="channel-image image" class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"

View File

@ -199,7 +199,7 @@ const openMenu = () => {
@click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)" @click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)"
> >
<i class="info icon" /> <i class="info icon" />
<span v-if="track.artist?.content_category === 'podcast'"> <span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
{{ $t('components.audio.PlayButton.button.episodeDetails') }} {{ $t('components.audio.PlayButton.button.episodeDetails') }}
</span> </span>
<span v-else> <span v-else>

View File

@ -109,12 +109,12 @@ const loopingTitle = computed(() => {
}) })
const hideArtist = () => { const hideArtist = () => {
if (currentTrack.value.artistId !== -1) { if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
return store.dispatch('moderation/hide', { return store.dispatch('moderation/hide', {
type: 'artist', type: 'artist',
target: { target: {
id: currentTrack.value.artistId, id: currentTrack.value.artistCredit[0].artist.id,
name: currentTrack.value.artistName name: currentTrack.value.artistCredit[0].artist.name
} }
}) })
} }
@ -178,13 +178,21 @@ const hideArtist = () => {
</router-link> </router-link>
</strong> </strong>
<div class="meta"> <div class="meta">
<router-link <span>
class="discrete link" <template
:to="{name: 'library.artists.detail', params: {id: currentTrack.artistId }}" v-for="ac in currentTrack.artistCredit"
@click.stop.prevent="" :key="ac.artist.id"
> >
{{ currentTrack.artistName ?? $t('components.audio.Player.meta.unknownArtist') }} <router-link
</router-link> class="discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent=""
>
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <span class="middle slash symbol" />
<router-link <router-link
@ -211,7 +219,13 @@ const hideArtist = () => {
{{ currentTrack.title }} {{ currentTrack.title }}
</strong> </strong>
<div class="meta"> <div class="meta">
{{ currentTrack.artistName ?? $t('components.audio.Player.meta.unknownArtist') }} <div
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span>
</div>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <span class="middle slash symbol" />
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Artist, Album } from '~/types' import type { Album, ArtistCredit } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ref, computed, reactive, watch, onMounted } from 'vue' import { ref, computed, reactive, watch, onMounted } from 'vue'
@ -27,7 +27,7 @@ const query = ref('')
const queryDebounced = refDebounced(query, 500) const queryDebounced = refDebounced(query, 500)
const results = reactive({ const results = reactive({
artists: [] as Artist[], artists: [] as ArtistCredit[],
albums: [] as Album[] albums: [] as Album[]
}) })
@ -94,9 +94,9 @@ const labels = computed(() => ({
<div v-if="results.artists.length > 0"> <div v-if="results.artists.length > 0">
<div class="ui cards"> <div class="ui cards">
<artist-card <artist-card
v-for="artist in results.artists" v-for="ac in results.artists"
:key="artist.id" :key="ac.artist.id"
:artist="artist" :artist="ac.artist"
/> />
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store' import { useStore } from '~/store'
import { generateTrackCreditString } from '~/utils/utils'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
@ -97,7 +98,7 @@ const categories = computed(() => [
name: labels.value.album, name: labels.value.album,
getId: (obj: Album) => obj.id, getId: (obj: Album) => obj.id,
getTitle: (obj: Album) => obj.title, getTitle: (obj: Album) => obj.title,
getDescription: (obj: Album) => obj.artist.name getDescription: (obj: Album) => generateTrackCreditString(obj)
}, },
{ {
code: 'tracks', code: 'tracks',
@ -105,7 +106,10 @@ const categories = computed(() => [
name: labels.value.track, name: labels.value.track,
getId: (obj: Track) => obj.id, getId: (obj: Track) => obj.id,
getTitle: (obj: Track) => obj.title, getTitle: (obj: Track) => obj.title,
getDescription: (obj: Track) => obj.album?.artist.name ?? obj.artist?.name ?? '' getDescription: (track: Track) => {
const album = track.album ?? null
return generateTrackCreditString(album) ?? generateTrackCreditString(track) ?? ''
}
}, },
{ {
code: 'tags', code: 'tags',

View File

@ -48,12 +48,18 @@ const imageUrl = computed(() => props.album.cover?.urls.original
</strong> </strong>
<div class="description"> <div class="description">
<span> <span>
<router-link <template
class="discrete link" v-for="ac in album.artist_credit"
:to="{name: 'library.artists.detail', params: {id: album.artist.id}}" :key="ac.artist.id"
> >
{{ album.artist.name }} <router-link
</router-link> class="discrete link"
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
>
{{ ac.credit }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span> </span>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@ import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue' import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
track: Track track: Track
@ -81,8 +82,8 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="track.artist?.cover" v-else-if="!!track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) " v-lazy="getArtistCoverUrl(track.artist_credit)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -109,7 +110,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
{{ track.title }} {{ track.title }}
</p> </p>
<p <p
v-if="track.artist?.content_category === 'podcast'" v-if="track.artist_credit?.[0].artist.content_category === 'podcast'"
class="track-meta mobile" class="track-meta mobile"
> >
<human-date <human-date
@ -126,7 +127,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
v-else v-else
class="track-meta mobile" class="track-meta mobile"
> >
{{ track.artist?.name }} {{ generateTrackCreditString(track) }}
<span class="middledot symbol" /> <span class="middledot symbol" />
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads[0] && track.uploads[0].duration"
@ -135,7 +136,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
</p> </p>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
:class="[ :class="[
'meta', 'meta',
'right', 'right',

View File

@ -10,6 +10,7 @@ import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Events { interface Events {
(e: 'update:show', value: boolean): void (e: 'update:show', value: boolean): void
@ -64,17 +65,17 @@ const favoriteButton = computed(() => isFavorite.value
: t('components.audio.podcast.Modal.button.addToFavorites') : t('components.audio.podcast.Modal.button.addToFavorites')
) )
const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const trackDetailsButton = computed(() => props.track.artist_credit?.[0].artist.content_category === 'podcast'
? t('components.audio.podcast.Modal.button.episodeDetails') ? t('components.audio.podcast.Modal.button.episodeDetails')
: t('components.audio.podcast.Modal.button.trackDetails') : t('components.audio.podcast.Modal.button.trackDetails')
) )
const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const albumDetailsButton = computed(() => props.track.artist_credit?.[0].artist?.content_category === 'podcast'
? t('components.audio.podcast.Modal.button.seriesDetails') ? t('components.audio.podcast.Modal.button.seriesDetails')
: t('components.audio.podcast.Modal.button.albumDetails') : t('components.audio.podcast.Modal.button.albumDetails')
) )
const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const artistDetailsButton = computed(() => props.track.artist_credit?.[0].artist?.content_category === 'podcast'
? t('components.audio.podcast.Modal.button.channelDetails') ? t('components.audio.podcast.Modal.button.channelDetails')
: t('components.audio.podcast.Modal.button.artistDetails') : t('components.audio.podcast.Modal.button.artistDetails')
) )
@ -118,11 +119,9 @@ const labels = computed(() => ({
class="ui centered image" class="ui centered image"
> >
<img <img
v-else-if="track.artist?.cover" v-else-if="!!track.artist_credit?.length && track.artist_credit[0].artist.cover"
v-lazy=" v-lazy="
$store.getters['instance/absoluteUrl']( getArtistCoverUrl(track.artist_credit)
track.artist.cover.urls.medium_square_crop
)
" "
alt="" alt=""
class="ui centered image" class="ui centered image"
@ -138,14 +137,14 @@ const labels = computed(() => ({
{{ track.title }} {{ track.title }}
</h3> </h3>
<h4 class="track-modal-subtitle"> <h4 class="track-modal-subtitle">
{{ track.artist?.name }} {{ generateTrackCreditString(track) }}
</h4> </h4>
</div> </div>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <div class="ui one column unstackable grid">
<div <div
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
class="row" class="row"
> >
<div <div
@ -254,20 +253,16 @@ const labels = computed(() => ({
class="row" class="row"
> >
<div <div
v-for="ac in track.artist_credit"
:key="ac.artist.id"
class="column" class="column"
role="button" role="button"
:aria-label="artistDetailsButton" :aria-label="artistDetailsButton"
@click.prevent.exact=" @click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
$router.push({
name: 'library.artists.detail',
params: { id: track.artist?.id },
})
"
> >
<i class="user icon track-modal list-icon" /> <i class="user icon track-modal list-icon" />
<span class="track-modal list-item">{{ <span class="track-modal list-item">{{ ac.artist.name }}</span>
artistDetailsButton <span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
}}</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -290,7 +285,7 @@ const labels = computed(() => ({
</div> </div>
<div class="ui divider" /> <div class="ui divider" />
<div <div
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })" v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
class="row" class="row"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Album, Playlist, Library, Channel, Actor, Cover } from '~/types' import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { ref } from 'vue' import { ref } from 'vue'
@ -26,7 +26,7 @@ interface Props extends PlayOptionsProps {
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean isPlayable?: boolean
artist?: Artist | null artistCredit?: ArtistCredit[] | null
album?: Album | null album?: Album | null
playlist?: Playlist | null playlist?: Playlist | null
library?: Library | null library?: Library | null
@ -40,7 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
defaultCover: null, defaultCover: null,
tracks: () => [], tracks: () => [],
artist: null, artistCredit: null,
album: null, album: null,
playlist: null, playlist: null,
library: null, library: null,

View File

@ -12,6 +12,7 @@ import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue' import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
track: Track track: Track
@ -81,8 +82,8 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="track.artist?.cover" v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)" v-lazy="getArtistCoverUrl(track.artist_credit)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -109,7 +110,7 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
{{ track.title }} {{ track.title }}
</p> </p>
<p class="track-meta mobile"> <p class="track-meta mobile">
{{ track.artist?.name }} {{ generateTrackCreditString(track) }}
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads[0] && track.uploads[0].duration"

View File

@ -10,7 +10,7 @@ import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Events { interface Events {
(e: 'update:show', value: boolean): void (e: 'update:show', value: boolean): void
} }
@ -64,17 +64,17 @@ const favoriteButton = computed(() => isFavorite.value
: t('components.audio.track.Modal.button.addToFavorites') : t('components.audio.track.Modal.button.addToFavorites')
) )
const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const trackDetailsButton = computed(() => props.track.artist_credit?.[0].artist.content_category === 'podcast'
? t('components.audio.track.Modal.button.episodeDetails') ? t('components.audio.track.Modal.button.episodeDetails')
: t('components.audio.track.Modal.button.trackDetails') : t('components.audio.track.Modal.button.trackDetails')
) )
const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const albumDetailsButton = computed(() => props.track.artist_credit?.[0].artist?.content_category === 'podcast'
? t('components.audio.track.Modal.button.seriesDetails') ? t('components.audio.track.Modal.button.seriesDetails')
: t('components.audio.track.Modal.button.albumDetails') : t('components.audio.track.Modal.button.albumDetails')
) )
const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast' const artistDetailsButton = computed(() => props.track.artist_credit?.[0].artist?.content_category === 'podcast'
? t('components.audio.track.Modal.button.channelDetails') ? t('components.audio.track.Modal.button.channelDetails')
: t('components.audio.track.Modal.button.artistDetails') : t('components.audio.track.Modal.button.artistDetails')
) )
@ -110,8 +110,8 @@ const labels = computed(() => ({
class="ui centered image" class="ui centered image"
> >
<img <img
v-else-if="track.artist?.cover" v-else-if="!!track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)" v-lazy="getArtistCoverUrl(track.artist_credit)"
alt="" alt=""
class="ui centered image" class="ui centered image"
> >
@ -126,14 +126,14 @@ const labels = computed(() => ({
{{ track.title }} {{ track.title }}
</h3> </h3>
<h4 class="track-modal-subtitle"> <h4 class="track-modal-subtitle">
{{ track.artist?.name }} {{ generateTrackCreditString(track) }}
</h4> </h4>
</div> </div>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <div class="ui one column unstackable grid">
<div <div
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'" v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
class="row" class="row"
> >
<div <div
@ -213,13 +213,16 @@ const labels = computed(() => ({
class="row" class="row"
> >
<div <div
v-for="ac in track.artist_credit"
:key="ac.artist.id"
class="column" class="column"
role="button" role="button"
:aria-label="artistDetailsButton" :aria-label="artistDetailsButton"
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: track.artist?.id } })" @click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
> >
<i class="user icon track-modal list-icon" /> <i class="user icon track-modal list-icon" />
<span class="track-modal list-item">{{ artistDetailsButton }}</span> <span class="track-modal list-item">{{ ac.credit }}</span>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -235,7 +238,7 @@ const labels = computed(() => ({
</div> </div>
<div class="ui divider" /> <div class="ui divider" />
<div <div
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })" v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
class="row" class="row"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' import type { Track, ArtistCredit, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -26,7 +26,7 @@ interface Props extends PlayOptionsProps {
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
tracks: Track[] tracks: Track[]
isPlayable?: boolean isPlayable?: boolean
artist?: Artist | null artistCredit?: ArtistCredit[] | null
album?: Album | null album?: Album | null
playlist?: Playlist | null playlist?: Playlist | null
library?: Library | null library?: Library | null
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
showPosition: false, showPosition: false,
displayActions: true, displayActions: true,
artist: null, artistCredit: null,
album: null, album: null,
playlist: null, playlist: null,
library: null, library: null,
@ -130,8 +130,8 @@ const hover = ref(false)
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="track.artist?.cover?.urls.original" v-else-if="track.artist_credit?.length && track.artist_credit[0].artist.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) " v-lazy="$store.getters['instance/absoluteUrl'](track.artist_credit[0].artist.cover.urls.medium_square_crop) "
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -166,15 +166,21 @@ const hover = ref(false)
v-if="showArtist" v-if="showArtist"
class="content ellipsis left floated column" class="content ellipsis left floated column"
> >
<router-link <template
class="artist link" v-for="ac in track.artist_credit"
:to="{ :key="ac.artist.id"
name: 'library.artists.detail',
params: { id: track.artist?.id },
}"
> >
{{ track.artist?.name }} <router-link
</router-link> class="artist link"
:to="{
name: 'library.artists.detail',
params: { id: ac.artist?.id },
}"
>
{{ ac.credit }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated" v-if="$store.state.auth.authenticated"

View File

@ -13,6 +13,8 @@ import TagsList from '~/components/tags/List.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import { getArtistCoverUrl } from '~/utils/utils'
interface Events { interface Events {
(e: 'count', count: number): void (e: 'count', count: number): void
} }
@ -117,8 +119,8 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
alt="" alt=""
> >
<img <img
v-else-if="object.track.artist?.cover" v-else-if="object.track.artist_credit && object.track.artist_credit.length > 0"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.artist.cover.urls.medium_square_crop)" v-lazy="getArtistCoverUrl(object.track.artist_credit)"
alt="" alt=""
> >
<img <img
@ -142,16 +144,20 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
</router-link> </router-link>
</div> </div>
<div <div
v-if="object.track.artist" v-if="object.track.artist_credit"
class="meta ellipsis" class="meta ellipsis"
> >
<span> <span
v-for="ac in object.track.artist_credit"
:key="ac.artist.id"
>
<router-link <router-link
class="discrete link" class="discrete link"
:to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}" :to="{ name: 'library.artists.detail', params: { id: ac.artist.id } }"
> >
{{ object.track.artist.name }} {{ ac.credit }}
</router-link> </router-link>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</span> </span>
</div> </div>
<tags-list <tags-list

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Album, Artist, Library } from '~/types' import type { Track, Album, Artist, Library, ArtistCredit } from '~/types'
import { momentFormat } from '~/utils/filters' import { momentFormat } from '~/utils/filters'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
@ -9,7 +9,7 @@ import { sum } from 'lodash-es'
import axios from 'axios' import axios from 'axios'
import ArtistLabel from '~/components/audio/ArtistLabel.vue' import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import AlbumDropdown from './AlbumDropdown.vue' import AlbumDropdown from './AlbumDropdown.vue'
@ -30,13 +30,21 @@ const props = defineProps<Props>()
const object = ref<Album | null>(null) const object = ref<Album | null>(null)
const artist = ref<Artist | null>(null) const artist = ref<Artist | null>(null)
const artistCredit = ref([] as ArtistCredit[])
const libraries = ref([] as Library[]) const libraries = ref([] as Library[])
const paginateBy = ref(50) const paginateBy = ref(50)
const totalTracks = computed(() => object.value?.tracks_count ?? 0) const totalTracks = computed(() => object.value?.tracks_count ?? 0)
const isChannel = computed(() => !!object.value?.artist.channel) const isChannel = computed(() => {
const isAlbum = computed(() => object.value?.artist.content_category === 'music') return artistCredit.value.some(ac => ac.artist.channel)
const isSerie = computed(() => object.value?.artist.content_category === 'podcast') })
const isAlbum = computed(() => {
return artistCredit.value.some(ac => ac.artist.content_category === 'music')
})
const isSerie = computed(() => {
return artistCredit.value.some(ac => ac.artist.content_category === 'podcast')
})
const totalDuration = computed(() => sum((object.value?.tracks ?? []).map(track => track.uploads[0]?.duration ?? 0))) const totalDuration = computed(() => sum((object.value?.tracks ?? []).map(track => track.uploads[0]?.duration ?? 0)))
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
@ -52,7 +60,10 @@ const fetchData = async () => {
isLoading.value = true isLoading.value = true
const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } }) const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } })
const artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
artistCredit.value = albumResponse.data.artist_credit
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
artist.value = artistResponse.data artist.value = artistResponse.data
if (artist.value?.channel) { if (artist.value?.channel) {
@ -201,7 +212,7 @@ const remove = async () => {
:is-album="isAlbum" :is-album="isAlbum"
:is-serie="isSerie" :is-serie="isSerie"
:is-channel="isChannel" :is-channel="isChannel"
:artist="artist" :artist-credit="artistCredit"
@remove="remove" @remove="remove"
/> />
</div> </div>
@ -214,9 +225,9 @@ const remove = async () => {
> >
{{ object.title }} {{ object.title }}
</h2> </h2>
<artist-label <artist-credit-label
v-if="artist" v-if="artistCredit"
:artist="artist" :artist-credit="artistCredit"
/> />
</header> </header>
</div> </div>
@ -244,10 +255,9 @@ const remove = async () => {
> >
{{ object.title }} {{ object.title }}
</h2> </h2>
<artist-label <artist-credit-label
v-if="artist" v-if="artistCredit"
:artist="artist" :artist-credit="artistCredit"
class="rounded"
/> />
</header> </header>
<div <div
@ -285,7 +295,7 @@ const remove = async () => {
:is-album="isAlbum" :is-album="isAlbum"
:is-serie="isSerie" :is-serie="isSerie"
:is-channel="isChannel" :is-channel="isChannel"
:artist="artist" :artist-credit="artistCredit"
@remove="remove" @remove="remove"
/> />
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local"> <div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
@ -333,7 +343,7 @@ const remove = async () => {
:paginate-by="paginateBy" :paginate-by="paginateBy"
:total-tracks="totalTracks" :total-tracks="totalTracks"
:is-serie="isSerie" :is-serie="isSerie"
:artist="artist" :artist-credit="artistCredit"
:object="object" :object="object"
:is-loading-tracks="isLoadingTracks" :is-loading-tracks="isLoadingTracks"
object-type="album" object-type="album"

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Artist, Album, Library, Track } from '~/types' import type { ArtistCredit, Album, Library, Track } from '~/types'
import LibraryWidget from '~/components/federation/LibraryWidget.vue' import LibraryWidget from '~/components/federation/LibraryWidget.vue'
import ChannelEntries from '~/components/audio/ChannelEntries.vue' import ChannelEntries from '~/components/audio/ChannelEntries.vue'
@ -17,7 +17,7 @@ interface Props {
isLoadingTracks: boolean isLoadingTracks: boolean
isSerie: boolean isSerie: boolean
artist: Artist artistCredit: ArtistCredit[]
paginateBy: number paginateBy: number
totalTracks: number totalTracks: number
} }
@ -66,10 +66,10 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</h2> </h2>
<channel-entries <channel-entries
v-if="artist.channel && isSerie" v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
:is-podcast="isSerie" :is-podcast="isSerie"
:limit="50" :limit="50"
:filters="{channel: artist.channel.uuid, album: object.id, ordering: '-creation_date'}" :filters="{channel: artistCredit[0].artist.channel.uuid, album: object.id, ordering: '-creation_date'}"
/> />
<template v-else> <template v-else>
@ -123,7 +123,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</div> </div>
</template> </template>
<template v-if="!artist.channel && !isSerie"> <template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
<h2> <h2>
{{ $t('components.library.AlbumDetail.header.libraries') }} {{ $t('components.library.AlbumDetail.header.libraries') }}
</h2> </h2>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Album, Artist, Library } from '~/types' import type { Album, ArtistCredit, Library } from '~/types'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -17,7 +17,7 @@ interface Events {
interface Props { interface Props {
isLoading: boolean isLoading: boolean
artist: Artist | null artistCredit: ArtistCredit[]
object: Album object: Album
publicLibraries: Library[] publicLibraries: Library[]
isAlbum: boolean isAlbum: boolean
@ -38,9 +38,9 @@ const labels = computed(() => ({
more: t('components.library.AlbumDropdown.button.more') more: t('components.library.AlbumDropdown.button.more')
})) }))
const isEmbedable = computed(() => (props.isChannel && props.artist?.channel?.actor) || props.publicLibraries.length) const isEmbedable = computed(() => (props.isChannel && props.artistCredit[0].artist?.channel?.actor) || props.publicLibraries.length)
const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null) const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null)
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist.name)}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist_credit[0].artist.name)}`)
const remove = () => emit('remove') const remove = () => emit('remove')
</script> </script>
@ -125,7 +125,7 @@ const remove = () => emit('remove')
{{ $t('components.library.AlbumDropdown.button.edit') }} {{ $t('components.library.AlbumDropdown.button.edit') }}
</router-link> </router-link>
<dangerous-button <dangerous-button
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername" v-if="artistCredit[0] && $store.state.auth.authenticated && artistCredit[0].artist.channel && artistCredit[0].artist.attributed_to?.full_username === $store.state.auth.fullUsername"
:class="['ui', {loading: isLoading}, 'item']" :class="['ui', {loading: isLoading}, 'item']"
@confirm="remove()" @confirm="remove()"
> >
@ -151,7 +151,7 @@ const remove = () => emit('remove')
</dangerous-button> </dangerous-button>
<div class="divider" /> <div class="divider" />
<div <div
v-for="obj in getReportableObjects({album: object, channel: artist?.channel})" v-for="obj in getReportableObjects({album: object, channel: artistCredit[0]?.artist.channel})"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
role="button" role="button"
class="basic item" class="basic item"

View File

@ -47,8 +47,8 @@ const domain = computed(() => getDomain(track.value?.fid ?? ''))
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length) const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length)
const upload = computed(() => track.value?.uploads?.[0] ?? null) const upload = computed(() => track.value?.uploads?.[0] ?? null)
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist?.name ?? ''}`)}`) const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist_credit?.[0].artist?.name ?? ''}`)}`)
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist_credit?.[0].artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`)
const downloadUrl = computed(() => { const downloadUrl = computed(() => {
const url = store.getters['instance/absoluteUrl'](upload.value?.listen_url ?? '') const url = store.getters['instance/absoluteUrl'](upload.value?.listen_url ?? '')
return store.state.auth.authenticated return store.state.auth.authenticated

View File

@ -76,7 +76,7 @@ watchEffect(() => {
class="ui fluid image track-cover-image" class="ui fluid image track-cover-image"
> >
<h3 class="ui header"> <h3 class="ui header">
<span v-if="track.artist?.content_category === 'music'"> <span v-if="track.artist_credit?.[0].artist?.content_category === 'music'">
{{ $t('components.library.TrackDetail.header.track') }} {{ $t('components.library.TrackDetail.header.track') }}
</span> </span>
<span v-else> <span v-else>
@ -169,14 +169,24 @@ watchEffect(() => {
{{ $t('components.library.TrackDetail.table.release.artist') }} {{ $t('components.library.TrackDetail.table.release.artist') }}
</td> </td>
<td class="right aligned"> <td class="right aligned">
<router-link :to="{name: 'library.artists.detail', params: {id: track.artist?.id}}"> <template
{{ track.artist?.name }} v-for="ac in track.artist_credit"
</router-link> :key="ac.artist.id"
>
<router-link
class="discrete link"
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
style="display: inline;"
>
{{ ac.credit }}
</router-link>
<span style="display: inline;">{{ ac.joinphrase }}</span>
</template>
</td> </td>
</tr> </tr>
<tr v-if="track.album"> <tr v-if="track.album">
<td> <td>
<span v-if="track.album.artist.content_category === 'music'"> <span v-if="track.album.artist_credit?.[0].artist.content_category === 'music'">
{{ $t('components.library.TrackDetail.table.release.album') }} {{ $t('components.library.TrackDetail.table.release.album') }}
</span> </span>
<span v-else> <span v-else>

Some files were not shown because too many files have changed in this diff Show More