Supporting multi-artist per tracks/albums (#1568)

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

View File

@ -40,11 +40,23 @@ def combined_recent(limit, **kwargs):
def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
querysets = [
Listening.objects.filter(query).select_related(
"track", "user", "track__artist", "track__album__artist"
Listening.objects.filter(query)
.select_related(
"track",
"user",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
),
TrackFavorite.objects.filter(query).select_related(
"track", "user", "track__artist", "track__album__artist"
TrackFavorite.objects.filter(query)
.select_related(
"track",
"user",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
),
]
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):
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
scope = common_filters.ActorScopeFilter(actor_field="attributed_to", distinct=True)
@ -33,7 +37,7 @@ class ChannelFilter(moderation_filters.HiddenContentFilterSet):
# tuple-mapping retains order
fields=(
("creation_date", "creation_date"),
("artist__modification_date", "modification_date"),
("artist_credit__artist__modification_date", "modification_date"),
("?", "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.moderation import mrf
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.tags import models as tags_models
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)
cover = CoverField(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(
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):
artist = SimpleChannelArtistSerializer()
@ -749,7 +753,7 @@ class RssFeedItemSerializer(serializers.Serializer):
else:
existing_track = (
music_models.Track.objects.filter(
uuid=expected_uuid, artist__channel=channel
uuid=expected_uuid, artist_credit__artist__channel=channel
)
.select_related("description", "attachment_cover")
.first()
@ -765,7 +769,6 @@ class RssFeedItemSerializer(serializers.Serializer):
"disc_number": validated_data.get("itunes_season", 1) or 1,
"position": validated_data.get("itunes_episode", 1) or 1,
"title": validated_data["title"],
"artist": channel.artist,
}
)
if "rights" in validated_data:
@ -801,6 +804,21 @@ class RssFeedItemSerializer(serializers.Serializer):
**track_kwargs,
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
# update or create, so we restore the cache by hand
if existing_track:

View File

@ -27,7 +27,7 @@ ARTIST_PREFETCH_QS = (
"attachment_cover",
)
.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()
if self.action == "retrieve":
queryset = queryset.annotate(
_downloads_count=Sum("artist__tracks__downloads_count")
_downloads_count=Sum("artist__artist_credit__tracks__downloads_count")
)
return queryset
@ -192,7 +192,6 @@ class ChannelViewSet(
if object.attributed_to == actors.get_service_actor():
# external feed, we redirect to the canonical one
return http.HttpResponseRedirect(object.rss_url)
uploads = (
object.library.uploads.playable_by(None)
.prefetch_related(

View File

@ -68,22 +68,33 @@ def create_taggable_items(dependency):
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",
"model": music_models.Track,
"factory": "music.Track",
"factory_kwargs": {"artist": None, "album": None},
"factory_kwargs": {"album": None},
"depends_on": [
{"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",
"model": music_models.Album,
"factory": "music.Album",
"factory_kwargs": {"artist": None},
"depends_on": [{"field": "artist", "id": "artists", "default_factor": 0.3}],
"factory_kwargs": {},
"depends_on": [
{"field": "artist_credit", "id": "artist_credit", "default_factor": 0.3}
],
},
{"id": "artists", "model": music_models.Artist, "factory": "music.Artist"},
{
@ -310,12 +321,23 @@ class Command(BaseCommand):
candidates = list(queryset.values_list("pk", flat=True))
picked_pks = [random.choice(candidates) for _ in objects]
picked_objects = {o.pk: o for o in queryset.filter(pk__in=picked_pks)}
saved_obj = []
for i, obj in enumerate(objects):
if create_dependencies:
value = random.choice(candidates)
else:
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:
objects = row["model"].objects.bulk_create(objects, batch_size=BATCH_SIZE)
results[row["id"]] = objects

View File

@ -43,9 +43,9 @@ def get_lb_listen(listening):
release_name = track.album.title
if track.album.mbid:
additional_info["release_mbid"] = str(track.album.mbid)
if track.artist.mbid:
additional_info["artist_mbids"] = [str(track.artist.mbid)]
mbids = [ac.artist.mbid for ac in track.artist_credit.all() if ac.artist.mbid]
if mbids:
additional_info["artist_mbids"] = mbids
upload = track.uploads.filter(duration__gte=0).first()
if upload:
@ -53,8 +53,8 @@ def get_lb_listen(listening):
return liblistenbrainz.Listen(
track_name=track.title,
artist_name=track.artist.name,
listened_at=listening.creation_date.timestamp(),
artist_name=track.get_artist_credit_string,
release_name=release_name,
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
payload = {
"key": api_key,
"artists": [track.artist.name],
"artists": [artist.name for artist in track.artist_credit.get_artists_list()],
"title": track.title,
"time": int(listening.creation_date.timestamp()),
"nofix": bool(conf.get("nofix")),
@ -46,8 +46,10 @@ def get_payload(listening, api_key, conf):
if track.album:
if track.album.title:
payload["album"] = track.album.title
if track.album.artist:
payload["albumartists"] = [track.album.artist.name]
if track.album.artist_credit.all():
payload["albumartists"] = [
artist.name for artist in track.album.artist_credit.get_artists_list()
]
upload = track.uploads.filter(duration__gte=0).first()
if upload:

View File

@ -84,7 +84,7 @@ def get_scrobble_payload(track, date, suffix="[0]"):
"""
upload = track.uploads.filter(duration__gte=0).first()
data = {
f"a{suffix}": track.artist.name,
f"a{suffix}": track.get_artist_credit_string,
f"t{suffix}": track.title,
f"l{suffix}": upload.duration if upload else 0,
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()
data = {
"artist": track.artist.name,
"artist": track.get_artist_credit_string,
"track": track.title,
"chosenByUser": 1,
}

View File

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

View File

@ -293,6 +293,7 @@ CONTEXTS = [
"Album": "fw:Album",
"Track": "fw:Track",
"Artist": "fw:Artist",
"ArtistCredit": "fw:ArtistCredit",
"Library": "fw:Library",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
@ -302,7 +303,16 @@ CONTEXTS = [
"track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"},
"artist": {"@id": "fw:artist", "@type": "@id"},
"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"},
"musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"},

View File

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

View File

@ -293,7 +293,7 @@ def inbox_delete_audio(payload, context):
upload_fids = [payload["object"]["id"]]
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)
@ -577,7 +577,9 @@ def inbox_delete_album(payload, context):
logger.debug("Discarding deletion of empty library")
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:
album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist:
@ -590,9 +592,10 @@ def inbox_delete_album(payload, context):
@outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context):
album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = (
album.artist.channel.actor
if album.artist.get_channel()
album_artist.channel.actor
if album_artist.get_channel()
else album.attributed_to
)
actor = actor or actors.get_service_actor()

View File

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

View File

@ -161,7 +161,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related("library__channel__actor", "track__artist"),
.prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer,
}
return get_collection_response(
@ -290,21 +292,21 @@ class MusicLibraryViewSet(
Prefetch(
"track",
queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover",
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"album__artist__attachment_cover",
"description",
).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag",
"album__tagged_items__tag",
"album__artist__tagged_items__tag",
"artist__tagged_items__tag",
"artist__description",
"album__artist_credit__artist__tagged_items__tag",
"album__artist_credit__artist__attachment_cover",
"artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description",
),
)
@ -331,15 +333,20 @@ class MusicUploadViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related(
"library__actor",
"track__artist",
"track__album__artist",
"track__description",
"track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__attachment_cover",
queryset = (
music_models.Upload.objects.local()
.select_related(
"library__actor",
"track__description",
"track__album__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
lookup_field = "uuid"
@ -393,13 +400,35 @@ class MusicArtistViewSet(
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(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related(
"artist__description", "description", "artist__attachment_cover"
queryset = (
music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
)
serializer_class = serializers.AlbumSerializer
lookup_field = "uuid"
@ -418,16 +447,22 @@ class MusicTrackViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related(
"album__artist",
"album__description",
"artist__description",
"description",
"attachment_cover",
"album__artist__attachment_cover",
"album__attachment_cover",
"artist__attachment_cover",
queryset = (
music_models.Track.objects.local()
.select_related(
"album__description",
"description",
"attachment_cover",
"album__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
lookup_field = "uuid"

View File

@ -51,10 +51,16 @@ class ListeningViewSet(
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
).select_related(
"artist", "album__artist", "attributed_to", "artist__attachment_cover"
tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
)
.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))

View File

@ -37,7 +37,7 @@ def get_content():
def get_top_music_categories():
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)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))
@ -47,7 +47,7 @@ def get_top_music_categories():
def get_top_podcast_categories():
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)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))

View File

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

View File

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

View File

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

View File

@ -4,11 +4,24 @@ from django_filters import rest_framework as filters
USER_FILTER_CONFIG = {
"ARTIST": {"target_artist": ["pk"]},
"CHANNEL": {"target_artist": ["artist__pk"]},
"ALBUM": {"target_artist": ["artist__pk"]},
"TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]},
"LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]},
"ALBUM": {"target_artist": ["artist_credit__artist__pk"]},
"TRACK": {
"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": {
"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")
class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD
artist = ArtistStateSerializer()
artist_credit = ArtistCreditStateSerializer(many=True)
class Meta:
model = music_models.Album
@ -103,7 +122,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid",
"creation_date",
"uuid",
"artist",
"artist_credit",
"release_date",
"tags",
"description",
@ -113,7 +132,7 @@ class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
@state_serializers.register(name="music.Track")
class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
tags = TAGS_FIELD
artist = ArtistStateSerializer()
artist_credit = ArtistCreditStateSerializer(many=True)
album = AlbumStateSerializer()
class Meta:
@ -125,7 +144,7 @@ class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
"fid",
"creation_date",
"uuid",
"artist",
"artist_credit",
"album",
"disc_number",
"position",
@ -230,6 +249,7 @@ TARGET_CONFIG = {
"id_field": serializers.UUIDField(),
},
"artist": {"queryset": music_models.Artist.objects.all()},
"artist_credit": {"queryset": music_models.ArtistCredit.objects.all()},
"album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()},
"library": {

View File

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

View File

@ -1,3 +1,4 @@
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
@ -159,3 +160,45 @@ class ReleaseDate(types.BooleanPreference):
verbose_name = "Release date Filter"
help_text = "Only content with a release date will be displayed"
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
from urllib.parse import urlparse
import factory
from django.conf import settings
from funkwhale_api.common import factories as common_factories
from funkwhale_api.factories import NoUpdateOnCreate, registry
@ -62,7 +64,7 @@ class ArtistFactory(
name = factory.Faker("name")
mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist")
playable = playable_factory("track__album__artist_credit__artist")
class Meta:
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
class AlbumFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
@ -84,7 +96,6 @@ class AlbumFactory(
title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4")
release_date = factory.Faker("date_object")
artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
playable = playable_factory("track__album")
@ -96,14 +107,22 @@ class AlbumFactory(
attributed = factory.Trait(
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
)
local = factory.Trait(
fid=factory.Faker("federation_url", local=True), artist__local=True
fid=factory.Faker("federation_url", local=True),
)
with_cover = factory.Trait(
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
class TrackFactory(
@ -132,22 +151,29 @@ class TrackFactory(
)
@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
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)
"""
# 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:
self.artist = extracted
self.artist_credit.add(extracted)
elif kwargs:
if created:
self.artist = ArtistFactory(**kwargs)
self.artist_credit.add(ArtistCreditFactory(**kwargs))
else:
self.artist = ArtistFactory.build(**kwargs)
self.artist_credit.add(ArtistCreditFactory.build(**kwargs))
elif self.album:
self.artist = self.album.artist
self.artist_credit.set(self.album.artist_credit.all())
if created:
self.save()
@ -195,7 +221,9 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
from funkwhale_api.audio import factories as audio_factories
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
content_category = filters.CharFilter("content_category")
scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor",
actor_field="artist_credit__tracks__uploads__library__actor",
distinct=True,
library_field="tracks__uploads__library",
library_field="artist_credit__tracks__uploads__library",
)
ordering = common_filters.CaseInsensitiveNameOrderingFilter(
fields=(
@ -128,14 +128,14 @@ class ArtistFilter(
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel"
library_filter_field = "track__artist"
library_filter_field = "track__artist_credit__artist"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct()
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):
return queryset.filter(mbid__isnull=(not value))
@ -149,8 +149,16 @@ class TrackFilter(
moderation_filters.HiddenContentFilterSet,
):
q = fields.SearchFilter(
search_fields=["title", "album__title", "artist__name"],
fts_search_fields=["body_text", "artist__body_text", "album__body_text"],
search_fields=[
"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")
tag = TAG_FILTER
@ -173,8 +181,11 @@ class TrackFilter(
("size", "size"),
("position", "position"),
("disc_number", "disc_number"),
("artist__name", "artist__name"),
("artist__modification_date", "artist__modification_date"),
("artist_credit__artist__name", "artist_credit__artist__name"),
(
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"),
("tag_matches", "related"),
)
@ -205,16 +216,19 @@ class TrackFilter(
"mbid": ["exact"],
}
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"
library_filter_field = "track"
artist_credit_filter_field = "artist__credit__artist"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value).distinct()
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):
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")
channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid")
track_artist = filters.UUIDFilter("track__artist_credit__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist_credit__artist__uuid")
library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter(
@ -273,7 +287,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
"mimetype",
"import_reference",
]
include_channels_field = "track__artist__channel"
include_channels_field = "track__artist_credit__artist__channel"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
@ -289,10 +303,10 @@ class AlbumFilter(
):
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(
search_fields=["title", "artist__name"],
fts_search_fields=["body_text", "artist__body_text"],
search_fields=["title", "artist_credit__artist__name"],
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
scope = common_filters.ActorScopeFilter(
actor_field="tracks__uploads__library__actor",
@ -305,7 +319,10 @@ class AlbumFilter(
("creation_date", "creation_date"),
("release_date", "release_date"),
("title", "title"),
("artist__modification_date", "artist__modification_date"),
(
"artist_credit__artist__modification_date",
"artist_credit__artist__modification_date",
),
("?", "random"),
("tag_matches", "related"),
)
@ -326,15 +343,18 @@ class AlbumFilter(
)
has_release_date = filters.BooleanFilter(
field_name="_",
method="filter_has_release_date",
field_name="_", method="filter_has_release_date"
)
artist = filters.ModelChoiceFilter(
field_name="_", method="filter_artist", queryset=models.Artist.objects.all()
)
class Meta:
model = models.Album
fields = ["artist", "mbid"]
fields = ["artist_credit", "mbid"]
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"
library_filter_field = "track__album"
@ -354,6 +374,9 @@ class AlbumFilter(
def filter_has_release_date(self, queryset, name, 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):
q = fields.SearchFilter(

View File

@ -12,11 +12,14 @@ class Importer:
def load(self, cleaned_data, raw_data, import_hooks):
mbid = cleaned_data.pop("mbid")
artists_credits = cleaned_data.pop("artist_credit", None)
# let's validate data, just in case
instance = self.model(**cleaned_data)
exclude = EXCLUDE_VALIDATION.get(self.model.__name__, [])
instance.full_clean(exclude=["mbid", "uuid", "fid", "from_activity"] + exclude)
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:
hook(m, cleaned_data, raw_data)
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 = (
existing_candidates.in_place()
.filter(source=source)
.select_related(
"track__attributed_to",
"track__artist",
"track__album__artist",
.select_related("track__attributed_to")
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
.first()
)
@ -839,9 +839,9 @@ def check_upload(stdout, upload):
" Cannot update track metadata, track belongs to someone else"
)
else:
track = models.Track.objects.select_related("artist", "album__artist").get(
pk=upload.track_id
)
track = models.Track.objects.prefetch_related(
"artist_credit__artist", "album__artist_credit__artist"
).get(pk=upload.track_id)
try:
tasks.update_track_metadata(upload.get_metadata(), track)
except serializers.ValidationError as e:

View File

@ -492,61 +492,76 @@ class ArtistField(serializers.Field):
return final
def to_internal_value(self, data):
# we have multiple values that can be separated by various separators
separators = [";", ","]
def _get_artist_credit_tuple(self, mbids, data):
from . import tasks
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
# ae29aae4-abfb-4609-8f54-417b1f4d64cc; 3237b5a8-ae44-400c-aa6d-cea51f0b9074;
raw_mbids = data["mbids"]
used_separator = None
mbids = [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:
if separator in data["artists"]:
names = [n.strip() for n in data["artists"].split(separator)]
if separator in raw_mbids:
mbids = [m.strip() for m in raw_mbids.split(separator)]
break
# corner case: 'album artist' field with only one artist but multiple names in 'artits' field
if (
not names
and data.get("names", None)
and any(separator in data["names"] for separator in separators)
):
names = [n.strip() for n in data["names"].split(separators[0])]
elif not names:
names = [data["artists"]]
elif used_separator and mbids:
names = [n.strip() for n in data["names"].split(used_separator)]
else:
names = [data["names"]]
return mbids
def _format_artist_credit_list(self, artists_credits_tuples, mbids):
final_artist_credits = []
for i, ac in enumerate(artists_credits_tuples):
artist_credit = {
"credit": ac[0],
"mbid": (mbids[i] if 0 <= i < len(mbids) else None),
"joinphrase": ac[1],
"index": i,
}
final_artist_credits.append(artist_credit)
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(
child=ArtistSerializer(strict=self.context.get("strict", True)),
min_length=1,
)
return field.to_internal_value(final)
return field.to_internal_value(final_artist_credits)
class AlbumField(serializers.Field):
@ -565,16 +580,17 @@ class AlbumField(serializers.Field):
"release_date": data.get("date", None),
"mbid": data.get("musicbrainz_albumid", None),
}
artists_field = ArtistField(for_album=True)
payload = artists_field.get_value(data)
artist_credit_field = ArtistField(for_album=True)
payload = artist_credit_field.get_value(data)
try:
artists = artists_field.to_internal_value(payload)
artist_credit = artist_credit_field.to_internal_value(payload)
except serializers.ValidationError as e:
artists = []
logger.debug("Ignoring validation error on album artists: %s", e)
artist_credit = []
logger.debug("Ignoring validation error on album artist_credit: %s", e)
album_serializer = AlbumSerializer(data=final)
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
@ -655,14 +671,17 @@ class MBIDField(serializers.UUIDField):
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()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
def __init__(self, *args, **kwargs):
self.strict = kwargs.pop("strict", True)
super().__init__(*args, **kwargs)
def validate_name(self, v):
def validate_credit(self, v):
if self.strict and not v:
raise serializers.ValidationError("This field is required.")
return v
@ -731,7 +750,7 @@ class TrackMetadataSerializer(serializers.Serializer):
description = DescriptionField(allow_null=True, allow_blank=True, required=False)
album = AlbumField()
artists = ArtistField()
artist_credit = ArtistField()
cover_data = CoverDataField(required=False)
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.common import fields
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.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
@ -112,7 +112,6 @@ class APIModelMixin(models.Model):
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
@ -171,12 +170,12 @@ class License(models.Model):
class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
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):
return self.prefetch_related(
models.Prefetch(
"albums",
"artist_credit__albums",
queryset=Album.objects.with_tracks_count().select_related(
"attachment_cover", "attributed_to"
),
@ -186,7 +185,7 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def annotate_playable_by_actor(self, actor):
tracks = (
Upload.objects.playable_by(actor)
.filter(track__artist=models.OuterRef("id"))
.filter(track__artist_credit__artist=models.OuterRef("id"))
.order_by("id")
.values("id")[:1]
)
@ -195,7 +194,9 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def playable_by(self, actor, include=True):
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:
return self.filter(pk__in=matches)
else:
@ -272,9 +273,25 @@ class Artist(APIModelMixin):
return None
def import_artist(v):
a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0]
return a
def import_artist_credit(v):
artists_credits = []
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):
@ -290,6 +307,39 @@ def import_tracks(instance, cleaned_data, raw_data):
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):
def with_tracks_count(self):
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):
tracks = (
Upload.objects.playable_by(actor)
.filter(track__album=models.OuterRef("id"))
.filter(track__artist_credit__albums=models.OuterRef("id"))
.order_by("id")
.values("id")[:1]
)
@ -329,7 +379,7 @@ class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
class Album(APIModelMixin):
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_group_id = models.UUIDField(null=True, blank=True)
attachment_cover = models.ForeignKey(
@ -380,9 +430,9 @@ class Album(APIModelMixin):
"title": {"musicbrainz_field_name": "title"},
"release_date": {"musicbrainz_field_name": "date", "converter": parse_date},
"type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
"artist": {
"artist_credit": {
"musicbrainz_field_name": "artist-credit",
"converter": import_artist,
"converter": import_artist_credit,
},
}
objects = AlbumQuerySet.as_manager()
@ -405,6 +455,13 @@ class Album(APIModelMixin):
kwargs.update({"title": title})
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):
MINIMUM_COUNT = 2
@ -428,11 +485,11 @@ def import_album(v):
class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
def for_nested_serialization(self):
return self.prefetch_related(
"artist",
"artist_credit",
Prefetch(
"album",
queryset=Album.objects.select_related(
"artist", "attachment_cover"
queryset=Album.objects.prefetch_related(
"artist_credit", "attachment_cover"
).annotate(_prefetched_tracks_count=Count("tracks")),
),
)
@ -485,7 +542,7 @@ def get_artist(release_list):
class Track(APIModelMixin):
mbid = models.UUIDField(db_index=True, null=True, blank=True)
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)
position = models.PositiveIntegerField(null=True, blank=True)
album = models.ForeignKey(
@ -527,11 +584,9 @@ class Track(APIModelMixin):
musicbrainz_mapping = {
"mbid": {"musicbrainz_field_name": "id"},
"title": {"musicbrainz_field_name": "title"},
"artist": {
"artist_credit": {
"musicbrainz_field_name": "artist-credit",
"converter": lambda v: Artist.get_or_create_from_api(
mbid=v[0]["artist"]["id"]
)[0],
"converter": import_artist_credit,
},
"album": {"musicbrainz_field_name": "release-list", "converter": import_album},
}
@ -559,19 +614,21 @@ class Track(APIModelMixin):
def get_moderation_url(self):
return f"/manage/library/tracks/{self.pk}"
def save(self, **kwargs):
try:
self.artist
except Artist.DoesNotExist:
self.artist = self.album.artist
super().save(**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()]
@property
def full_name(self):
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:
return f"{self.artist.name} - {self.title}"
return f"{self.get_artist_credit_string} - {self.title}"
@property
def cover(self):
@ -587,6 +644,9 @@ class Track(APIModelMixin):
kwargs.update({"title": title})
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
def get_or_create_from_release(cls, release_mbid, mbid):
release_mbid = str(release_mbid)
@ -609,33 +669,42 @@ class Track(APIModelMixin):
if not track_data:
raise ValueError("No track found matching this ID")
track_artist_mbid = None
for ac in track_data["recording"]["artist-credit"]:
artists_credits = []
for i, ac in enumerate(track_data["recording"]["artist-credit"]):
try:
ac_mbid = ac["artist"]["id"]
except TypeError:
# it's probably a string, like "feat."
# it's probably a string, like "feat.".
continue
if ac_mbid == str(album.artist.mbid):
continue
track_artist = Artist.get_or_create_from_api(ac_mbid)[0]
track_artist_mbid = ac_mbid
break
track_artist_mbid = track_artist_mbid or album.artist.mbid
if track_artist_mbid == str(album.artist.mbid):
track_artist = album.artist
else:
track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0]
return cls.objects.update_or_create(
if "joinphrase" not in ac:
joinphrase = ""
else:
joinphrase = ac["joinphrase"]
artist_credit, create = ArtistCredit.objects.get_or_create(
artist=track_artist,
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,
defaults={
"position": int(track["position"]),
"title": track["recording"]["title"],
"album": album,
"artist": track_artist,
},
)
track[0].artist_credit.set(artists_credits)
return track
@property
def listen_url(self) -> str:
@ -809,7 +878,7 @@ class Upload(models.Model):
title_parts.append(self.track.title)
if self.track.album:
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)
filename = f"{title}.{extension}"
@ -1032,9 +1101,21 @@ class Upload(models.Model):
if self.track.album
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

View File

@ -126,7 +126,11 @@ class TrackMutationSerializer(CoverMutation, TagMutation, DescriptionMutation):
return serialized_relations
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:
upload = channel.library.uploads.filter(track=obj).first()
if upload:

View File

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

View File

@ -24,7 +24,9 @@ def get_twitter_card_metas(type, id):
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:
obj = queryset.get()
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": "music:album:disc", "content": obj.disc_number},
{"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:
metas.append(
@ -119,7 +125,7 @@ def library_track(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:
obj = queryset.get()
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:title", "content": obj.title},
{"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:
metas.append(
{
@ -206,7 +216,10 @@ def library_artist(request, pk, redirect_to_ap):
)
# we use latest album's cover as artist image
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 = [
{"tag": "meta", "property": "og:url", "content": artist_url},
@ -234,7 +247,10 @@ def library_artist(request, pk, redirect_to_ap):
)
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)
.exists()
):

View File

@ -2,6 +2,7 @@ import collections
import datetime
import logging
import os
import re
from django.conf import settings
from django.core.cache import cache
@ -9,7 +10,7 @@ from django.db import transaction
from django.db.models import F, Q
from django.dispatch import receiver
from django.utils import timezone
from musicbrainzngs import ResponseError
from musicbrainzngs import NetworkError, ResponseError
from requests.exceptions import RequestException
from funkwhale_api import musicbrainz
@ -226,7 +227,7 @@ def process_upload(upload, update_denormalization=True):
forced_values["artist"] = upload.library.channel.artist
old_status = upload.import_status
additional_data = {"upload_source": upload.source}
upload_source = {"upload_source": upload.source}
if use_file_metadata:
audio_file = upload.get_audio_file()
@ -256,11 +257,11 @@ def process_upload(upload, update_denormalization=True):
)
final_metadata = collections.ChainMap(
additional_data, serializer.validated_data, internal_config
upload_source, serializer.validated_data, internal_config
)
else:
final_metadata = collections.ChainMap(
additional_data,
upload_source,
forced_values,
internal_config,
)
@ -270,8 +271,8 @@ def process_upload(upload, update_denormalization=True):
)
except UploadImportError as e:
return fail_import(upload, e.code)
except Exception:
fail_import(upload, "unknown_error")
except Exception as e:
fail_import(upload, "unknown_error", e)
raise
broadcast = getter(
@ -395,38 +396,53 @@ def federation_audio_track_to_metadata(payload, references):
"cover_data": get_cover(payload["album"], "image"),
"release_date": payload["album"].get("released"),
"tags": [t["name"] for t in payload["album"].get("tags", []) or []],
"artists": [
"artist_credit": [
{
"fid": a["id"],
"name": a["name"],
"fdate": a["published"],
"cover_data": get_cover(a, "image"),
"description": a.get("description"),
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
"tags": [t["name"] for t in a.get("tags", []) or []],
"artist": {
"fid": a["artist"]["id"],
"name": a["artist"]["name"],
"fdate": a["artist"]["published"],
"cover_data": get_cover(a["artist"], "image"),
"description": a["artist"].get("description"),
"attributed_to": references.get(
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"],
"name": a["name"],
"fdate": a["published"],
"description": a.get("description"),
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
"tags": [t["name"] for t in a.get("tags", []) or []],
"cover_data": get_cover(a, "image"),
"artist": {
"fid": a["artist"]["id"],
"name": a["artist"]["name"],
"fdate": a["artist"]["published"],
"description": a["artist"].get("description"),
"attributed_to": references.get(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 []],
"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
"fid": payload["id"],
"fdate": payload["published"],
"tags": [t["name"] for t in payload.get("tags", []) or []],
}
return new_data
@ -434,6 +450,7 @@ def get_owned_duplicates(upload, track):
"""
Ensure we skip duplicate tracks to avoid wasting user/instance storage
"""
owned_libraries = upload.library.actor.libraries.all()
return (
models.Upload.objects.filter(
@ -491,9 +508,11 @@ def sort_candidates(candidates, important_fields):
@transaction.atomic
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:
populate_album_cover(track.album, source=data.get("upload_source"))
return track
@ -505,7 +524,7 @@ def truncate(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")
track_uuid = getter(data, "funkwhale", "track", "uuid")
@ -548,64 +567,64 @@ def _get_track(data, attributed_to=None, **forced_values):
except IndexError:
pass
# get / create artist and album artist
artists = getter(data, "artists", default=[])
# get / create artist, artist_credit and album artist, album artist_credit
album_artists_credits = None
artist_credit_data = getter(data, "artist_credit", default=[])
if "artist" in forced_values:
artist = forced_values["artist"]
else:
artist_data = artists[0]
artist = get_artist(
artist_data, attributed_to=attributed_to, from_activity_id=from_activity_id
query = Q(artist=artist)
defaults = {
"artist": artist,
"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:
album = forced_values["album"]
album_artists_credits = track_artists_credits
else:
if "artist" in forced_values:
album_artist = forced_values["artist"]
else:
album_artists = getter(data, "album", "artists", default=artists) or artists
album_artist_data = album_artists[0]
album_artist_name = album_artist_data.get("name")
if album_artist_name == artist_name:
album_artist = artist
else:
query = Q(name__iexact=album_artist_name)
album_artist_mbid = album_artist_data.get("mbid", None)
album_artist_fid = album_artist_data.get("fid", None)
if album_artist_mbid:
query |= Q(mbid=album_artist_mbid)
if album_artist_fid:
query |= Q(fid=album_artist_fid)
defaults = {
"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 album_artists_credits:
pass
mbid = query_mb and (data.get("musicbrainz_albumid", None) or album_mbid)
try:
album_artists_credits = get_or_create_artists_credits_from_musicbrainz(
"release",
mbid,
attributed_to=attributed_to,
from_activity_id=from_activity_id,
)
except (NoMbid, ResponseError, NetworkError):
if album_artists := getter(data, "album", "artist_credit", default=None):
album_artists_credits = (
get_or_create_artists_credits_from_artist_credit_metadata(
album_artists,
attributed_to=attributed_to,
from_activity_id=from_activity_id,
)
)
if created:
tags_models.add_tags(
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"),
)
else:
album_artists_credits = track_artists_credits
# get / create album
if "album" in data:
@ -616,13 +635,15 @@ def _get_track(data, attributed_to=None, **forced_values):
if album_mbid:
query = Q(mbid=album_mbid)
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:
query |= Q(fid=album_fid)
defaults = {
"title": album_title,
"artist": album_artist,
"mbid": album_mbid,
"release_date": album_data.get("release_date"),
"fid": album_fid,
@ -635,6 +656,8 @@ def _get_track(data, attributed_to=None, **forced_values):
album, created = get_best_candidate_or_create(
models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
)
album.artist_credit.set(album_artists_credits)
if created:
tags_models.add_tags(album, *album_data.get("tags", []))
common_utils.attach_content(
@ -682,7 +705,7 @@ def _get_track(data, attributed_to=None, **forced_values):
query = Q(
title__iexact=track_title,
artist=artist,
artist_credit__in=track_artists_credits,
album=album,
position=position,
disc_number=disc_number,
@ -695,17 +718,10 @@ def _get_track(data, attributed_to=None, **forced_values):
if 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 = {
"title": track_title,
"album": album,
"mbid": track_mbid,
"artist": artist,
"position": position,
"disc_number": disc_number,
"fid": track_fid,
@ -732,27 +748,36 @@ def _get_track(data, attributed_to=None, **forced_values):
if sync_mb_tag and track_mbid:
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(track)
track.artist_credit.set(track_artists_credits)
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")
artist_mbid = artist_data.get("mbid", None)
artist_fid = artist_data.get("fid", None)
artist_name = artist_data["name"]
mbid = artist_data.get("artist", {}).get("mbid", None)
fid = artist_data.get("artist", {}).get("fid", None)
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:
query = Q(mbid=artist_mbid)
if mbid:
query = Q(mbid=mbid)
else:
query = Q(name__iexact=artist_name)
if artist_fid:
query |= Q(fid=artist_fid)
query = Q(name__iexact=name)
if fid:
query |= Q(fid=fid)
defaults = {
"name": artist_name,
"mbid": artist_mbid,
"fid": artist_fid,
"name": name,
"mbid": mbid,
"fid": fid,
"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"):
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"]
)
if created:
tags_models.add_tags(artist, *artist_data.get("tags", []))
common_utils.attach_content(
artist, "description", artist_data.get("description")
)
common_utils.attach_file(
artist, "attachment_cover", artist_data.get("cover_data")
)
if sync_mb_tag and artist_mbid:
tags_models.add_tags(artist, *tags)
common_utils.attach_content(artist, "description", description)
common_utils.attach_file(artist, "attachment_cover", cover)
if sync_mb_tag and mbid:
tags_tasks.sync_fw_item_tag_with_musicbrainz_tags(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)
def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kwargs):
user = upload.library.actor.get_user()
@ -895,7 +1063,7 @@ def get_prunable_albums():
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):
@ -926,8 +1094,8 @@ UPDATE_CONFIG = {
)
},
},
"artists": {},
"album": {"title": {}, "mbid": {}, "release_date": {}},
"artist": {"name": {}, "mbid": {}},
"album_artist": {"name": {}, "mbid": {}},
}
@ -941,11 +1109,15 @@ def update_track_metadata(audio_metadata, track):
to_update = [
("track", track, lambda data: data),
("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",
track.album.artist if track.album else None,
lambda data: data["album"]["artists"][0],
track.album.artist_credit.all() if track.album else None,
lambda data: data["album"]["artist_credit"],
),
]
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)
except IndexError:
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():
getter = config.get(
"getter", lambda data, field: data[config.get("field", field)]
@ -972,7 +1192,6 @@ def update_track_metadata(audio_metadata, track):
if obj_updated_fields:
obj.save(update_fields=obj_updated_fields)
tags_models.set_tags(track, *new_data.get("tags", []))
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})
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 = (
models.Artist.objects.all()
.prefetch_related("attributed_to", "attachment_cover")
.select_related("attributed_to", "attachment_cover")
.order_by("-id")
.prefetch_related(
"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
permission_classes = [oauth_permissions.ScopePermission]
@ -166,12 +162,12 @@ class ArtistViewSet(
utils.get_actor_from_request(self.request)
)
return queryset.prefetch_related(
Prefetch("albums", queryset=albums), TAG_PREFETCH
Prefetch("artist_credit__albums", queryset=albums), TAG_PREFETCH
)
libraries = get_libraries(
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 = (
models.Album.objects.all()
.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
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
@ -219,8 +216,8 @@ class AlbumViewSet(
def get_queryset(self):
queryset = super().get_queryset()
if self.action in ["destroy"]:
queryset = queryset.exclude(artist__channel=None).filter(
artist__attributed_to=self.request.user.actor
queryset = queryset.exclude(artist_credit__artist__channel=None).filter(
artist_credit__artist__attributed_to=self.request.user.actor
)
tracks = models.Track.objects.all().prefetch_related("album")
@ -246,6 +243,54 @@ class AlbumViewSet(
)
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(
mixins.CreateModelMixin,
@ -404,7 +449,7 @@ class TrackViewSet(
.for_nested_serialization()
.prefetch_related("attributed_to", "attachment_cover")
.order_by("-creation_date")
)
).distinct()
serializer_class = serializers.TrackSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
@ -426,8 +471,8 @@ class TrackViewSet(
def get_queryset(self):
queryset = super().get_queryset()
if self.action in ["destroy"]:
queryset = queryset.exclude(artist__channel=None).filter(
artist__attributed_to=self.request.user.actor
queryset = queryset.exclude(artist_credit__artist__channel=None).filter(
artist_credit__artist__attributed_to=self.request.user.actor
)
filter_favorites = self.request.GET.get("favorites", None)
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):
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:
queryset = queryset.filter(uuid=explicit_file)
queryset = queryset.playable_by(actor)
@ -723,8 +770,8 @@ class UploadViewSet(
.order_by("-creation_date")
.prefetch_related(
"library__actor",
"track__artist",
"track__album__artist",
"track__artist_credit",
"track__album__artist_credit",
"track__attachment_cover",
)
)
@ -743,7 +790,7 @@ class UploadViewSet(
"import_date",
"bitrate",
"size",
"artist__name",
"artist_credit__artist__name",
)
def get_queryset(self):
@ -846,20 +893,24 @@ class Search(views.APIView):
def get_tracks(self, query):
query_obj = utils.get_fts_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,
)
qs = (
models.Track.objects.all()
.filter(query_obj)
.prefetch_related(
"artist",
"artist_credit",
"attributed_to",
Prefetch(
"album",
queryset=models.Album.objects.select_related(
"artist", "attachment_cover", "attributed_to"
).prefetch_related("tracks"),
"attachment_cover", "attributed_to"
).prefetch_related("tracks", "artist_credit"),
),
)
)
@ -867,13 +918,15 @@ class Search(views.APIView):
def get_albums(self, 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 = (
models.Album.objects.all()
.filter(query_obj)
.select_related("artist", "attachment_cover", "attributed_to")
.prefetch_related("tracks__artist")
.select_related("attachment_cover", "attributed_to")
.prefetch_related("tracks__artist_credit", "artist_credit")
)
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.set_useragent("funkwhale", str(__version__), settings.FUNKWHALE_URL)
_api.set_hostname(settings.MUSICBRAINZ_HOSTNAME)
_api.set_format(fmt="json")
def clean_artist_search(query, **kwargs):

View File

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

View File

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

View File

@ -95,7 +95,9 @@ class PlaylistSerializer(serializers.ModelSerializer):
covers = []
max_covers = 5
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
url = plt.track.album.attachment_cover.download_url_medium_square_crop
if url in covers:

View File

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

View File

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

View File

@ -63,7 +63,9 @@ class SessionRadio(SimpleRadio):
qs = (
Track.objects.all()
.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(
@ -164,7 +166,7 @@ class SessionRadio(SimpleRadio):
class RandomRadio(SessionRadio):
def get_queryset(self, **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")
@ -174,7 +176,9 @@ class RandomLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"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("?")
@ -189,7 +193,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
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")
@ -363,7 +369,7 @@ class ArtistRadio(RelatedObjectRadio):
def get_queryset(self, **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")
@ -376,7 +382,7 @@ class LessListenedRadio(SessionRadio):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
return (
qs.filter(artist__content_category="music")
qs.filter(artist_credit__artist__content_category="music")
.exclude(pk__in=listened)
.order_by("?")
)
@ -394,7 +400,9 @@ class LessListenedLibraryRadio(SessionRadio):
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"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("?")
@ -450,7 +458,7 @@ class RecentlyAdded(SessionRadio):
date = datetime.date.today() - datetime.timedelta(days=30)
qs = super().get_queryset(**kwargs)
return qs.filter(
Q(artist__content_category="music"),
Q(artist_credit__artist__content_category="music"),
Q(creation_date__gt=date),
)

View File

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

View File

@ -36,7 +36,7 @@ def get_valid_filepart(s):
def get_track_path(track, suffix):
parts = []
parts.append(get_valid_filepart(track.artist.name))
parts.append(get_valid_filepart(track.get_artist_credit_string))
if track.album:
parts.append(get_valid_filepart(track.album.title))
track_part = get_valid_filepart(track.title) + "." + suffix
@ -79,7 +79,7 @@ class GetArtistsSerializer(serializers.Serializer):
class GetArtistSerializer(serializers.Serializer):
def to_representation(self, artist):
albums = artist.albums.prefetch_related("tracks__uploads")
albums = artist.artist_credit.albums().prefetch_related("tracks__uploads")
payload = {
"id": artist.pk,
"name": artist.name,
@ -128,7 +128,7 @@ def get_track_data(album, track, upload):
"isDir": "false",
"title": track.title,
"album": album.title if album else "",
"artist": track.artist.name,
"artist": track.get_artist_credit_string,
"track": track.position 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
@ -144,7 +144,11 @@ def get_track_data(album, track, upload):
"duration": upload.duration or 0,
"created": to_subsonic_date(track.creation_date),
"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",
"mediaType": "song",
"musicBrainzId": str(track.mbid or ""),
@ -165,9 +169,9 @@ def get_track_data(album, track, upload):
def get_album2_data(album):
payload = {
"id": album.id,
"artistId": album.artist.id,
"artistId": album.artist_credit.all()[0].artist.pk,
"name": album.title,
"artist": album.artist.name,
"artist": album.get_artist_credit_string,
"created": to_subsonic_date(album.creation_date),
"duration": album.duration,
"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}
tracks = (
music_models.Track.objects.filter(pk__in=by_track_id.keys())
.select_related("album__artist")
.prefetch_related("album__artist_credit__artist")
.prefetch_related("uploads")
)
tracks = tracks.order_by("-creation_date")
@ -261,7 +265,7 @@ def get_playlist_data(playlist):
def get_playlist_detail_data(playlist):
data = get_playlist_data(playlist)
qs = (
playlist.playlist_tracks.select_related("track__album__artist")
playlist.playlist_tracks.prefetch_related("track__album__artist_credit__artist")
.prefetch_related("track__uploads")
.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"
)
@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,
)
def get_album(self, request, *args, **kwargs):
@ -292,7 +294,9 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def stream(self, request, *args, **kwargs):
data = request.GET or request.POST
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)
if not sorted_uploads:
@ -416,9 +420,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
queryset.playable_by(actor)
.filter(
Q(tagged_items__tag__name=genre)
| Q(artist__tagged_items__tag__name=genre)
| Q(album__artist__tagged_items__tag__name=genre)
| Q(album__tagged_items__tag__name=genre)
| Q(artist_credit__artist__tagged_items__tag__name=genre)
| Q(
artist_credit__albums__artist_credit__artist__tagged_items__tag__name=genre
)
| Q(artist_credit__albums__tagged_items__tag__name=genre)
)
.prefetch_related("uploads")
.distinct()
@ -457,7 +463,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
)
.with_tracks_count()
.with_duration()
.order_by("artist__name")
.order_by("artist_credit__artist__name")
)
data = request.GET or request.POST
filterset = filters.AlbumList2FilterSet(data, queryset=queryset)
@ -480,7 +486,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
genre = data.get("genre")
queryset = queryset.filter(
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":
try:
@ -549,7 +555,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"queryset": (
music_models.Album.objects.with_duration()
.with_tracks_count()
.select_related("artist")
.prefetch_related("artist_credit__artist")
),
"serializer": serializers.get_album_list2_data,
},
@ -559,7 +565,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
"queryset": (
music_models.Track.objects.prefetch_related(
"uploads"
).select_related("album__artist")
).prefetch_related("artist_credit__albums__artist_credit__artist")
),
"serializer": serializers.get_song_list_data,
},

View File

@ -22,19 +22,21 @@ def get_tags_from_foreign_key(
"""
data = {}
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")
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():
# loop on all objects, store the objs tags + counter on the corresponding foreign key
row_data = data.setdefault(
getattr(obj, 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["tags"].append(ti.tag_id)
for ac in obj.artist_credit.all():
# loop on all objects, store the objs tags + counter on the corresponding foreign key
row_data = data.setdefault(
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["tags"].append(ti.tag_id)
# now, keep only tags that are present on all objects, i.e tags where the count
# matches total_objs

View File

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

View File

@ -678,7 +678,7 @@ def test_rss_feed_item_serializer_create(factories):
assert upload.duration == 1357
assert upload.mimetype == "audio/mpeg"
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.position == 33
assert upload.track.disc_number == 2
@ -702,7 +702,7 @@ def test_rss_feed_item_serializer_update(factories):
track__uuid=expected_uuid,
source="https://file.domain/audio.mp3",
library=channel.library,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
)
track = upload.track
@ -748,7 +748,7 @@ def test_rss_feed_item_serializer_update(factories):
assert upload.duration == 1357
assert upload.mimetype == "audio/mpeg"
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.position == 33
assert upload.track.disc_number == 2

View File

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

View File

@ -23,6 +23,13 @@ def test_load_test_data_dry_run(factories, mocker):
{"create_dependencies": True, "artists": 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},
[
@ -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.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):
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)
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):
attachments = [
factories["music.Album"](
with_cover=True, artist__attachment_cover=None
with_cover=True, artist_credit__artist__attachment_cover=None
).attachment_cover,
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):
factories["favorites.TrackFavorite"]()
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()
filterset = filters.TrackFavoriteFilter(
{"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"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_fav = factories["favorites.TrackFavorite"](
track__album__artist=cf.target_artist
track__album__artist_credit__artist=cf.target_artist
)
qs = models.TrackFavorite.objects.all()
filterset = filters.TrackFavoriteFilter(

View File

@ -353,7 +353,7 @@ def test_inbox_create_audio(factories, mocker):
def test_inbox_create_audio_channel(factories, mocker):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
album = factories["music.Album"](artist_credit__artist=channel.artist)
upload = factories["music.Upload"](
track__album=album,
library=channel.library,
@ -423,7 +423,7 @@ def test_inbox_delete_album(factories):
def test_inbox_delete_album_channel(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
album = factories["music.Album"](artist_credit__artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
@ -454,7 +454,7 @@ def test_outbox_delete_album(factories):
def test_outbox_delete_album_channel(factories):
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]
expected = serializers.ActivitySerializer(
{"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):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
upload = factories["music.Upload"](track__artist=channel.artist)
upload = factories["music.Upload"](track__artist_credit__artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
@ -816,7 +816,7 @@ def test_inbox_update_audio(factories, mocker, r_mock):
channel = factories["audio.Channel"]()
upload = factories["music.Upload"](
library=channel.library,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
track__attributed_to=channel.actor,
)
upload.track.fid = upload.fid

View File

@ -787,11 +787,11 @@ def test_activity_pub_album_serializer_to_ap(factories):
"musicbrainzId": album.mbid,
"published": album.creation_date.isoformat(),
"released": album.release_date.isoformat(),
"artists": [
serializers.ArtistSerializer(
album.artist, context={"include_ap_context": False}
).data
],
"artist_credit": serializers.ArtistCreditSerializer(
album.artist_credit.all(),
many=True,
context={"include_ap_context": False},
).data,
"attributedTo": album.attributed_to.fid,
"mediaType": "text/html",
"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):
channel = factories["audio.Channel"]()
album = factories["music.Album"](
artist=channel.artist,
artist_credit__artist=channel.artist,
)
serializer = serializers.AlbumSerializer(album)
assert serializer.data["artists"] == [
{"type": channel.actor.type, "id": channel.actor.fid}
assert serializer.data["artist_credit"] == [
{
"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):
actor = factories["federation.Actor"]()
artist = factories["music.Artist"]()
artist_credit = factories["music.ArtistCredit"](artist=artist)
released = faker.date_object()
payload = {
"@context": jsonld.get_default_context(),
@ -831,11 +851,9 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
"musicbrainzId": faker.uuid4(),
"published": now.isoformat(),
"released": released.isoformat(),
"artists": [
serializers.ArtistSerializer(
artist, context={"include_ap_context": False}
).data
],
"artist_credit": serializers.ArtistCreditSerializer(
[artist_credit], many=True, context={"include_ap_context": False}
).data,
"attributedTo": actor.fid,
"tag": [
{"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 str(album.mbid) == payload["musicbrainzId"]
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.mimetype == payload["image"]["mediaType"]
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(
factories, faker, now
factories, faker, now, mocker
):
actor = factories["federation.Actor"]()
channel = factories["audio.Channel"]()
ac = factories["music.ArtistCredit"](artist=channel.artist)
released = faker.date_object()
payload = {
"@context": jsonld.get_default_context(),
@ -872,15 +892,40 @@ def test_activity_pub_album_serializer_from_ap_create_channel_artist(
"name": faker.sentence(),
"published": now.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,
}
mocker.patch.object(utils, "retrieve_ap_object", return_value=ac)
serializer = serializers.AlbumSerializer(data=payload)
assert serializer.is_valid(raise_exception=True) is True
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):
@ -895,11 +940,11 @@ def test_activity_pub_album_serializer_from_ap_update(factories, faker):
"musicbrainzId": faker.uuid4(),
"published": album.creation_date.isoformat(),
"released": released.isoformat(),
"artists": [
serializers.ArtistSerializer(
album.artist, context={"include_ap_context": False}
).data
],
"artist_credit": serializers.ArtistCreditSerializer(
album.artist_credit.all(),
many=True,
context={"include_ap_context": False},
).data,
"attributedTo": album.attributed_to.fid,
"tag": [
{"type": "Hashtag", "name": "#Punk"},
@ -946,11 +991,11 @@ def test_activity_pub_track_serializer_to_ap(factories):
"disc": track.disc_number,
"license": track.license.conf["identifiers"][0],
"copyright": "test",
"artists": [
serializers.ArtistSerializer(
track.artist, context={"include_ap_context": False}
).data
],
"artist_credit": serializers.ArtistCreditSerializer(
track.artist_credit.all(),
many=True,
context={"include_ap_context": False},
).data,
"album": serializers.AlbumSerializer(
track.album, context={"include_ap_context": False}
).data,
@ -1014,41 +1059,53 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
"mediaType": "image/png",
},
"tag": [{"type": "Hashtag", "name": "AlbumTag"}],
"artists": [
"artist_credit": [
{
"type": "Artist",
"mediaType": "text/plain",
"content": "Artist summary",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"attributedTo": album_artist_attributed_to.fid,
"tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}],
"image": {
"type": "Image",
"url": "https://cover.image/album-artist.png",
"mediaType": "image/png",
"artist": {
"type": "Artist",
"mediaType": "text/plain",
"content": "Artist summary",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"attributedTo": album_artist_attributed_to.fid,
"tag": [{"type": "Hashtag", "name": "AlbumArtistTag"}],
"image": {
"type": "Image",
"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",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"mediaType": "text/plain",
"content": "Other artist summary",
"musicbrainzId": str(uuid.uuid4()),
"attributedTo": artist_attributed_to.fid,
"published": published.isoformat(),
"tag": [{"type": "Hashtag", "name": "ArtistTag"}],
"image": {
"type": "Image",
"url": "https://cover.image/artist.png",
"mediaType": "image/png",
"artist": {
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"mediaType": "text/plain",
"content": "Other artist summary",
"musicbrainzId": str(uuid.uuid4()),
"attributedTo": artist_attributed_to.fid,
"published": published.isoformat(),
"tag": [{"type": "Hashtag", "name": "ArtistTag"}],
"image": {
"type": "Image",
"url": "https://cover.image/artist.png",
"mediaType": "image/png",
},
},
"joinphrase": "",
"credit": "Credit Name",
"published": published.isoformat(),
"id": "http://hello.artistcredit",
}
],
"tag": [
@ -1061,8 +1118,8 @@ def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
track = serializer.save()
album = track.album
artist = track.artist
album_artist = track.album.artist
artist = track.artist_credit.all()[0].artist
album_artist = track.album.artist_credit.all()[0].artist
assert track.from_activity == activity
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 artist.from_activity == activity
assert artist.name == data["artists"][0]["name"]
assert artist.fid == data["artists"][0]["id"]
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
assert artist.name == data["artist_credit"][0]["artist"]["name"]
assert track.artist_credit.all()[0].credit == data["artist_credit"][0]["credit"]
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.attributed_to == artist_attributed_to
assert artist.description.text == data["artists"][0]["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 artist.description.text == data["artist_credit"][0]["artist"]["content"]
assert (
album_artist.description.content_type
== data["album"]["artists"][0]["mediaType"]
artist.description.content_type
== data["artist_credit"][0]["artist"]["mediaType"]
)
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 (
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"])
@ -1144,7 +1223,9 @@ def test_activity_pub_track_serializer_from_ap_update(factories, r_mock, mocker,
"content": "hello there",
"attributedTo": track_attributed_to.fid,
"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()},
"tag": [
{"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",
"mediaType": "image/png",
},
"artists": [
"artist_credit": [
{
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"artist": {
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
},
"joinphrase": "",
"credit": "John Smith Credit",
"published": published.isoformat(),
"id": "http://hello.artistcredit",
}
],
},
"artists": [
"artist_credit": [
{
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()),
"artist": {
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
},
"joinphrase": "",
"credit": "Bob Smith Credit",
"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):
library = factories["music.Library"]()
upload = factories["music.Upload"](library=library, track__album__with_cover=True)
data = {
"@context": jsonld.get_default_context(),
"type": "Audio",
@ -1631,7 +1723,7 @@ def test_channel_actor_outbox_serializer(factories):
channel = factories["audio.Channel"]()
uploads = factories["music.Upload"].create_batch(
5,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
library=channel.library,
import_status="finished",
)
@ -1671,7 +1763,8 @@ def test_channel_upload_serializer(factories):
track__license="cc0-1.0",
track__copyright="Copyright something",
track__album__set_tags=["Rock"],
track__artist__set_tags=["Indie"],
track__artist_credit__artist__set_tags=["Indie"],
track__artist_credit__artist__local=True,
)
expected = {
@ -1722,9 +1815,9 @@ def test_channel_upload_serializer(factories):
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")
album = factories["music.Album"](artist=channel.artist)
album = factories["music.Album"](artist_credit__artist=channel.artist)
payload = {
"@context": jsonld.get_default_context(),
"type": "Audio",
@ -1767,6 +1860,7 @@ def test_channel_upload_serializer_from_ap_create(factories, now):
"url": "https://image.example/image.png",
},
}
mocker.patch.object(utils, "retrieve_ap_object", return_value=album)
serializer = serializers.ChannelUploadSerializer(
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.bitrate == payload["url"][1]["bitrate"]
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.disc_number == payload["disc"]
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
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")
album = factories["music.Album"](artist=channel.artist)
upload = factories["music.Upload"](track__album=album, track__artist=channel.artist)
album = factories["music.Album"](artist_credit__artist=channel.artist)
upload = factories["music.Upload"](
track__album=album, track__artist_credit__artist=channel.artist
)
payload = {
"@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",
},
}
mocker.patch.object(utils, "retrieve_ap_object", return_value=album)
serializer = serializers.ChannelUploadSerializer(
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.bitrate == payload["url"][1]["bitrate"]
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.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to
@ -1944,3 +2041,23 @@ def test_report_serializer_to_ap(factories):
}
serializer = serializers.FlagSerializer(report)
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):
factories["history.Listening"]()
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()
filterset = filters.ListeningFilter(
{"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"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_listening = factories["history.Listening"](
track__album__artist=cf.target_artist
track__album__artist_credit__artist=cf.target_artist
)
qs = models.Listening.objects.all()
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),
"release_date": album.release_date.isoformat(),
"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(
album.attributed_to
).data,
@ -412,7 +418,13 @@ def test_manage_track_serializer(factories, now, to_api_date):
"copyright": track.copyright,
"license": track.license,
"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,
"attributed_to": serializers.ManageBaseActorSerializer(
track.attributed_to

View File

@ -216,7 +216,9 @@ def test_album_list(factories, superuser_api_client, settings):
album = factories["music.Album"]()
factories["music.Album"]()
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

View File

@ -33,7 +33,7 @@ def test_album_filter_hidden(factories, mocker, queryset_equal_list):
factories["music.Album"]()
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()
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):
factories["music.Track"]()
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()
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):
factories["music.Track"]()
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()
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):
channel = factories["audio.Channel"](library__privacy_level="everyone")
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"]()
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):
channel = factories["audio.Channel"](library__privacy_level="everyone")
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"]()
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")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Artist"]()
qs = upload.track.artist.__class__.objects.all()
qs = models.Artist.objects.all()
filterset = filters.ArtistFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track.artist]
assert filterset.qs == [upload.track.artist_credit.all()[0].artist]
def test_track_filter_artist_includes_album_artist(
@ -218,12 +224,13 @@ def test_track_filter_artist_includes_album_artist(
factories["music.Track"]()
track1 = 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()
filterset = filters.TrackFilter(
{"artist": track1.artist.pk},
{"artist": track1.artist_credit.all()[0].artist.pk},
request=mocker.Mock(user=anonymous_user),
queryset=qs,
)

View File

@ -312,23 +312,29 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.mp3",
{
"title": "Bend",
"artists": [
"artist_credit": [
{
"name": "Binärpilot",
"credit": "Binärpilot",
"mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
"joinphrase": "; ",
},
{
"credit": "Another artist",
"mbid": None,
"joinphrase": "",
},
{"name": "Another artist", "mbid": None},
],
"album": {
"title": "You Can't Stop Da Funk",
"mbid": uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124"),
"release_date": datetime.date(2006, 2, 7),
"artists": [
"artist_credit": [
{
"name": "Binärpilot",
"credit": "Binärpilot",
"mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"),
"joinphrase": "; ",
},
{"name": "Another artist", "mbid": None},
{"credit": "Another artist", "mbid": None, "joinphrase": ""},
],
},
"position": 2,
@ -344,25 +350,32 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.ogg",
{
"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"),
"joinphrase": "; ",
},
{
"credit": "Musopen Symphony Orchestra",
"mbid": None,
"joinphrase": "",
},
{"name": "Musopen Symphony Orchestra", "mbid": None},
],
"album": {
"title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15),
"artists": [
"artist_credit": [
{
"name": "Edvard Grieg",
"credit": "Edvard Grieg",
"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"),
"joinphrase": "",
},
],
},
@ -379,25 +392,32 @@ def test_metadata_fallback_ogg_theora(mocker):
"test.opus",
{
"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"),
"joinphrase": "; ",
},
{
"credit": "Musopen Symphony Orchestra",
"mbid": None,
"joinphrase": "",
},
{"name": "Musopen Symphony Orchestra", "mbid": None},
],
"album": {
"title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15),
"artists": [
"artist_credit": [
{
"name": "Edvard Grieg",
"credit": "Edvard Grieg",
"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"),
"joinphrase": "",
},
],
},
@ -414,20 +434,22 @@ def test_metadata_fallback_ogg_theora(mocker):
"test_theora.ogg",
{
"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"),
"joinphrase": "",
}
],
"album": {
"title": "Ballast der Republik",
"mbid": uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf"),
"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"),
"joinphrase": "",
}
],
},
@ -446,20 +468,22 @@ def test_metadata_fallback_ogg_theora(mocker):
"sample.flac",
{
"title": "999,999",
"artists": [
"artist_credit": [
{
"name": "Nine Inch Nails",
"credit": "Nine Inch Nails",
"mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"),
"joinphrase": "",
}
],
"album": {
"title": "The Slip",
"mbid": uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1"),
"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"),
"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"),
"joinphrase": "; ",
},
{
"name": "World",
"credit": "World",
"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"),
"joinphrase": "; ",
},
{
"name": "World",
"credit": "World",
"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(
"data, errored_field",
[
({"name": "Hello", "mbid": "wrong-uuid"}, "mbid"), # wrong uuid
({"name": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, "name"),
({"credit": "Hello", "mbid": "wrong-uuid"}, "mbid"), # wrong uuid
({"credit": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, "credit"),
],
)
def test_artist_serializer_validation(data, errored_field):
@ -584,24 +612,27 @@ def test_fake_metadata_with_serializer():
expected = {
"title": "Peer Gynt Suite no. 1, op. 46: I. Morning",
"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"),
"joinphrase": "",
}
],
"album": {
"title": "Peer Gynt Suite no. 1, op. 46",
"mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"),
"release_date": datetime.date(2012, 8, 15),
"artists": [
"artist_credit": [
{
"name": "Edvard Grieg",
"credit": "Edvard Grieg",
"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"),
"joinphrase": "",
},
],
"cover_data": None,
@ -614,6 +645,7 @@ def test_fake_metadata_with_serializer():
}
serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data))
assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == expected
@ -626,12 +658,12 @@ def test_serializer_album_artist_missing():
expected = {
"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": {
"title": "Peer Gynt Suite no. 1, op. 46",
"mbid": None,
"release_date": None,
"artists": [],
"artist_credit": [],
"cover_data": None,
},
}
@ -654,12 +686,12 @@ def test_serializer_album_artist_missing():
def test_serializer_album_default_title_when_missing_or_empty(data):
expected = {
"title": "Track",
"artists": [{"name": "Artist", "mbid": None}],
"artist_credit": [{"credit": "Artist", "mbid": None, "joinphrase": ""}],
"album": {
"title": metadata.UNKNOWN_ALBUM,
"mbid": None,
"release_date": None,
"artists": [],
"artist_credit": [],
"cover_data": None,
},
}
@ -681,12 +713,12 @@ def test_serializer_empty_fields(field_name):
}
expected = {
"title": "Track Title",
"artists": [{"name": "Track Artist", "mbid": None}],
"artist_credit": [{"credit": "Track Artist", "mbid": None, "joinphrase": ""}],
"album": {
"title": "Track Album",
"mbid": None,
"release_date": None,
"artists": [],
"artist_credit": [],
"cover_data": None,
},
}
@ -698,12 +730,12 @@ def test_serializer_empty_fields(field_name):
def test_serializer_strict_mode_false():
data = {}
expected = {
"artists": [{"name": None, "mbid": None}],
"artist_credit": [],
"album": {
"title": "[Unknown Album]",
"mbid": None,
"release_date": None,
"artists": [],
"artist_credit": [],
"cover_data": None,
},
}
@ -730,11 +762,21 @@ def test_artist_field_featuring():
"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()
value = field.get_value(data)
assert field.to_internal_value(value) == expected
@ -756,23 +798,23 @@ def test_acquire_tags_from_genre(genre, expected_tags):
data = {
"title": "Track Title",
"artist": "Track Artist",
# "artist_credit": [{"credit": "Track Artist"}],
"album": "Track Album",
"genre": genre,
}
expected = {
"title": "Track Title",
"artists": [{"name": "Track Artist", "mbid": None}],
"artist_credit": [{"credit": "Track Artist", "mbid": None, "joinphrase": ""}],
"album": {
"title": "Track Album",
"mbid": None,
"release_date": None,
"artists": [],
"artist_credit": [],
"cover_data": None,
},
}
if expected_tags:
expected["tags"] = expected_tags
serializer = metadata.TrackMetadataSerializer(data=metadata.FakeMetadata(data))
assert serializer.is_valid(raise_exception=True) is True
assert serializer.validated_data == expected

View File

@ -27,6 +27,48 @@
# upload_manager = new_apps.get_model(a, "Upload")
# for upload in mapping_list:
# upload_obj = upload_manager.objects.get(pk=upload[4])
# 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=[])
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):
@ -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)
assert track.title == track_data["recording"]["title"]
assert track.mbid == track_data["recording"]["id"]
assert track.album == album
assert track.artist == artist
assert track.artist_credit.all()[0].albums.all()[0] == album
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"])
@ -144,12 +208,11 @@ def test_import_track_with_different_artist_than_release(factories, mocker):
mocker.patch(
"funkwhale_api.musicbrainz.api.recordings.get", return_value=recording_data
)
track = models.Track.get_or_create_from_api(recording_data["recording"]["id"])[0]
assert track.title == recording_data["recording"]["title"]
assert track.mbid == recording_data["recording"]["id"]
assert track.album == album
assert track.artist == artist
assert track.artist_credit.all()[0].artist == artist
@pytest.mark.parametrize(
@ -391,7 +454,7 @@ def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
queryset = models.Artist.objects.playable_by(
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
if 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(
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
if 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,
)
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
if 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.title, "Hypnotize"
assert album.release_date, datetime.date(2005, 1, 1)
assert album.artist.name, "System of a Down"
assert album.artist.mbid, data["artist-credit"][0]["artist"]["id"]
assert album.artist_credit.all()[0].artist.name, "System of a Down"
assert album.artist_credit.all()[0].artist.mbid, data["artist-credit"][0]["artist"][
"id"
]
assert album.fid == federation_utils.full_url(
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 data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed"
assert track.mbid == data["id"]
assert track.artist.pk is not None
assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801"
assert track.artist.name == "Adhesive Wombat"
assert track.artist_credit.all()[0].artist.pk is not None
assert (
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 track.album.title == "Marsupial Madness"
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 data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed"
assert track.mbid == data["id"]
assert track.artist.pk is not None
assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801"
assert track.artist.name == "Adhesive Wombat"
assert track.artist_credit.all()[0].artist.pk is not None
assert (
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"])
assert not created

View File

@ -38,7 +38,7 @@ def test_artist_album_serializer(factories, to_api_date):
"fid": album.fid,
"mbid": str(album.mbid),
"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),
"tracks_count": 1,
"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):
actor = factories["federation.Actor"]()
track = factories["music.Track"](
album__artist__attributed_to=actor, album__artist__with_cover=True
artist_credit = factories["music.ArtistCredit"](
artist__attributed_to=actor, artist__with_cover=True
)
artist = track.artist
artist = artist.__class__.objects.with_albums().get(pk=artist.pk)
album = list(artist.albums.all())[0]
setattr(artist, "_prefetched_tracks", range(42))
track = factories["music.Track"](
album__artist_credit=artist_credit, artist_credit=artist_credit
)
artist = track.artist_credit.all()[0].artist
album = artist.artist_credit.all()[0].albums.all()[0]
setattr(artist, "_tracks_count", 42)
expected = {
"id": artist.id,
"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):
actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](attributed_to=actor, artist__with_cover=True)
track = factories["music.Track"](album__artist=channel.artist)
artist = track.artist
artist = factories["music.Artist"](with_cover=True, attributed_to=actor)
channel = factories["audio.Channel"](attributed_to=actor, artist=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)
album = list(artist.albums.all())[0]
setattr(artist, "_prefetched_tracks", range(42))
album = list(artist.artist_credit.all()[0].albums.all())[0]
setattr(artist, "_tracks_count", 42)
expected = {
"id": artist.id,
"fid": artist.fid,
@ -110,7 +117,9 @@ def test_artist_with_albums_serializer_channel(factories, to_api_date):
},
}
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):
@ -177,7 +186,9 @@ def test_album_serializer(factories, to_api_date):
"fid": album.fid,
"mbid": str(album.mbid),
"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),
"is_playable": False,
"duration": 0,
@ -207,7 +218,9 @@ def test_track_album_serializer(factories, to_api_date):
"fid": album.fid,
"mbid": str(album.mbid),
"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),
"is_playable": False,
"cover": common_serializers.AttachmentSerializer(album.attachment_cover).data,
@ -239,7 +252,9 @@ def test_track_serializer(factories, to_api_date):
expected = {
"id": track.id,
"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,
"mbid": str(track.mbid),
"title": track.title,

View File

@ -41,7 +41,10 @@ def test_library_track(spa_html, no_api_auth, client, factories, settings):
"property": "music:musician",
"content": utils.join_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",
"content": utils.join_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):
album = factories["music.Album"](with_cover=True)
factories["music.Upload"](playable=True, track__album=album)
artist = album.artist
artist = album.artist_credit.all()[0].artist
url = f"/library/artists/{artist.pk}"
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 pytest
from django.db.models import Prefetch
from django.db.models import Count, Prefetch
from django.urls import reverse
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"](
library__privacy_level="everyone",
import_status="finished",
track__album__artist__set_tags=tags,
track__album__artist_credit__artist__set_tags=tags,
).track
artist = track.artist
artist = track.artist_credit.all()[0].artist
request = api_request.get("/")
qs = artist.__class__.objects.with_albums().prefetch_related(
Prefetch("tracks", to_attr="_prefetched_tracks")
qs = artist.__class__.objects.with_albums().annotate(
_tracks_count=Count("artist_credit__tracks")
)
serializer = serializers.ArtistWithAlbumsSerializer(
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}
for artist in serializer.data:
artist["tags"] = tags
for album in artist["albums"]:
album["is_playable"] = True
url = reverse("api:v1:artists-list")
response = logged_in_api_client.get(url)
assert serializer.data[0]["tracks_count"] == 1
assert response.status_code == 200
assert response.data == expected
@ -118,7 +117,9 @@ def test_artist_view_filter_playable(param, expected, factories, api_request):
"empty": factories["music.Artist"](),
"full": factories["music.Upload"](
library__privacy_level="everyone", import_status="finished"
).track.artist,
)
.track.artist_credit.all()[0]
.artist,
}
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)
url = reverse("api:v1:uploads-list")
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(
url,
{
@ -968,12 +969,14 @@ def test_can_get_libraries_for_music_entities(
library = upload.library
setattr(library, "_uploads_count", 1)
data = {
"artist": upload.track.artist,
"artist": upload.track.artist_credit.all()[0].artist,
"album": upload.track.album,
"track": upload.track,
}
# 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"](
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,
"height": 150,
"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,
"thumbnail_url": federation_utils.full_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(
iframe_src
),
"author_name": track.artist.name,
"author_name": track.artist_credit.all()[0].artist.name,
"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,
"height": 400,
"width": 600,
"title": f"{album.title} by {album.artist.name}",
"description": 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_credit.all()[0].artist.name}",
"thumbnail_url": federation_utils.full_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(
iframe_src
),
"author_name": album.artist.name,
"author_name": album.artist_credit.all()[0].artist.name,
"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"
track = factories["music.Track"](album__with_cover=True)
album = track.album
artist = track.artist
artist = track.artist_credit.all()[0].artist
url = reverse("api:v1:oembed")
artist_url = f"https://test.com/library/artists/{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):
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")
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):
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")
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()
channel = factories["audio.Channel"](attributed_to=actor, artist__with_cover=True)
attachment = factories["common.Attachment"](actor=actor)
ac = factories["music.ArtistCredit"](artist=channel.artist)
url = reverse("api:v1:albums-list")
data = {
"artist": channel.artist.pk,
"artist_credit": ac.pk,
"cover": attachment.uuid,
"title": "Hello world",
"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")
assert response.status_code == 201
album = channel.artist.albums.get(title=data["title"])
assert response.status_code == 204
album = channel.artist.artist_credit.albums().get(title=data["title"])
assert (
response.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")
actor = logged_in_api_client.user.create_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})
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):
logged_in_api_client.user.create_actor()
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})
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")
actor = logged_in_api_client.user.create_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)
upload2 = factories["music.Upload"](track=track)
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):
logged_in_api_client.user.create_actor()
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})
response = logged_in_api_client.delete(url)
@ -1597,3 +1605,13 @@ def test_fs_import_cancel_already_running(
assert response.status_code == 204
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"]()
factories["playlists.PlaylistTrack"]()
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]

View File

@ -1,3 +1,5 @@
from itertools import chain
import pytest
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):
artist1 = factories["music.Artist"]()
artist2 = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist1)
factories["music.Track"].create_batch(3, artist=artist2)
candidates = artist1.tracks.order_by("pk")
factories["music.Track"].create_batch(3, artist_credit__artist=artist1)
factories["music.Track"].create_batch(3, artist_credit__artist=artist2)
candidates = list(
chain(*[ac.tracks.order_by("pk") for ac in artist1.artist_credit.all()])
)
f = {"filters": [{"type": "artist", "ids": [artist1.pk]}]}
url = reverse("api:v1:radios:radios-validate")
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
expected = {
"count": candidates.count(),
"count": len(candidates),
"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):
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]
factories["music.Upload"].create_batch(5)
@ -208,7 +210,9 @@ def test_can_start_artist_radio(factories):
user = factories["users.User"]()
artist = factories["music.Artist"]()
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]
radio = radios.ArtistRadio()
@ -224,7 +228,7 @@ def test_can_start_tag_radio(factories):
good_tracks = [
factories["music.Track"](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"])
@ -358,7 +362,7 @@ def test_session_radio_get_queryset_ignore_filtered_track_artist(
factories, queryset_equal_list
):
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"]()
radio = radios.RandomRadio()
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
):
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"]()
radio = radios.RandomRadio()
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"]()
excluded_artist = factories["music.Artist"]()
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"](
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"}
for key, value in map_filters_to_type.items():
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}]}
response = api_client.post(
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):
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]
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",
"title": "foo",
"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",
"title": "sla/sh",
"position": 12,
@ -76,8 +76,8 @@ def test_get_artists_serializer(factories):
name="", with_cover=False
) # Shouldn't be serialised
factories["music.Album"].create_batch(size=3, artist=artist1)
factories["music.Album"].create_batch(size=2, artist=artist2)
factories["music.Album"].create_batch(size=3, artist_credit__artist=artist1)
factories["music.Album"].create_batch(size=2, artist_credit__artist=artist2)
expected = {
"ignoredArticles": "",
@ -125,7 +125,7 @@ def test_get_artists_serializer(factories):
def test_get_artist_serializer(factories):
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)
expected = {
@ -191,7 +191,7 @@ def test_get_track_data_content_type(mimetype, extension, expected, factories):
def test_get_album_serializer(factories):
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)
upload = factories["music.Upload"](track=track, bitrate=42000, duration=43, size=44)
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):
artist = factories["music.Artist"]()
album = factories["music.Album"](artist=artist)
artist_credit = factories["music.ArtistCredit"]()
album = factories["music.Album"](artist_credit=artist_credit)
track = factories["music.Track"](album=album)
upload = factories["music.Upload"](track=track)
favorite = factories["favorites.TrackFavorite"](track=track)
@ -347,7 +347,7 @@ def test_channel_episode_serializer(factories):
description = factories["common.Content"]()
channel = factories["audio.Channel"]()
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"](
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")
assert url.endswith("getArtist") is 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")
expected = {"artist": serializers.GetArtistSerializer(artist).data}
@ -202,9 +204,9 @@ def test_get_album(
):
url = reverse("api:subsonic:subsonic-get_album")
assert url.endswith("getAlbum") is True
artist = factories["music.Artist"]()
artist_credit = factories["music.ArtistCredit"]()
album = (
factories["music.Album"](artist=artist)
factories["music.Album"](artist_credit=artist_credit)
.__class__.objects.with_duration()
.first()
)
@ -217,7 +219,10 @@ def test_get_album(
assert response.data == expected
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")
assert url.endswith("getSong") is True
artist = factories["music.Artist"]()
album = factories["music.Album"](artist=artist)
artist_credit = factories["music.ArtistCredit"]()
album = factories["music.Album"](artist_credit=artist_credit)
track = factories["music.Track"](album=album, playable=True)
upload = factories["music.Upload"](track=track)
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")
assert url.endswith("getAlbumList2") is True
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]
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]
factories["music.Album"](playable=True, set_tags=["Pop"])
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(
"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):
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"](
playable=True,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
library=channel.library,
bitrate=128000,
duration=42,
)
upload2 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
library=channel.library,
bitrate=256000,
duration=43,
)
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["federation.Follow"]()
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"](
playable=True,
track__artist=channel1.artist,
track__artist_credit__artist=channel1.artist,
library=channel1.library,
bitrate=128000,
duration=42,
)
factories["music.Upload"](
playable=True,
track__artist=channel2.artist,
track__artist_credit__artist=channel2.artist,
library=channel2.library,
bitrate=256000,
duration=43,
@ -983,14 +997,14 @@ def test_get_newest_podcasts(logged_in_api_client, factories, mocker):
)
upload1 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
library=channel.library,
bitrate=128000,
duration=42,
)
upload2 = factories["music.Upload"](
playable=True,
track__artist=channel.artist,
track__artist_credit__artist=channel.artist,
library=channel.library,
bitrate=256000,
duration=43,

View File

@ -6,9 +6,11 @@ def test_get_tags_from_foreign_key(factories):
rock_tag = factories["tags.Tag"](name="Rock")
rap_tag = factories["tags.Tag"](name="Rap")
artist = factories["music.Artist"]()
factories["music.Track"].create_batch(3, artist=artist, set_tags=["rock", "rap"])
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(

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import type { QueueItemSource } from '~/types'
import time from '~/utils/time'
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
interface Events {
(e: 'play', index: number): void
@ -43,7 +44,7 @@ defineProps<Props>()
>
<strong>{{ source.title }}</strong><br>
<span>
{{ source.artistName }}
{{ generateTrackCreditStringFromQueue(source) }}
</span>
</button>
</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}})"
>
<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)"
class="channel-image image"
@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}/`)"
>
<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') }}
</span>
<span v-else>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport'
import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Events {
(e: 'update:show', value: boolean): void
@ -64,17 +65,17 @@ const favoriteButton = computed(() => isFavorite.value
: 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.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.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.artistDetails')
)
@ -118,11 +119,9 @@ const labels = computed(() => ({
class="ui centered image"
>
<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
)
getArtistCoverUrl(track.artist_credit)
"
alt=""
class="ui centered image"
@ -138,14 +137,14 @@ const labels = computed(() => ({
{{ track.title }}
</h3>
<h4 class="track-modal-subtitle">
{{ track.artist?.name }}
{{ generateTrackCreditString(track) }}
</h4>
</div>
<div class="ui hidden divider" />
<div class="content">
<div class="ui one column unstackable grid">
<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"
>
<div
@ -254,20 +253,16 @@ const labels = computed(() => ({
class="row"
>
<div
v-for="ac in track.artist_credit"
:key="ac.artist.id"
class="column"
role="button"
: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" />
<span class="track-modal list-item">{{
artistDetailsButton
}}</span>
<span class="track-modal list-item">{{ ac.artist.name }}</span>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</div>
</div>
<div class="row">
@ -290,7 +285,7 @@ const labels = computed(() => ({
</div>
<div class="ui divider" />
<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"
class="row"
@click.stop.prevent="report(obj)"

View File

@ -1,5 +1,5 @@
<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 { ref } from 'vue'
@ -26,7 +26,7 @@ interface Props extends PlayOptionsProps {
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean
artist?: Artist | null
artistCredit?: ArtistCredit[] | null
album?: Album | null
playlist?: Playlist | null
library?: Library | null
@ -40,7 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
defaultCover: null,
tracks: () => [],
artist: null,
artistCredit: null,
album: null,
playlist: null,
library: null,

View File

@ -12,6 +12,7 @@ import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Props extends PlayOptionsProps {
track: Track
@ -81,8 +82,8 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
class="ui artist-track mini image"
>
<img
v-else-if="track.artist?.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="getArtistCoverUrl(track.artist_credit)"
alt=""
class="ui artist-track mini image"
>
@ -109,7 +110,7 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
{{ track.title }}
</p>
<p class="track-meta mobile">
{{ track.artist?.name }}
{{ generateTrackCreditString(track) }}
<span class="middle middledot symbol" />
<human-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 useReport from '~/composables/moderation/useReport'
import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
interface Events {
(e: 'update:show', value: boolean): void
}
@ -64,17 +64,17 @@ const favoriteButton = computed(() => isFavorite.value
: 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.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.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.artistDetails')
)
@ -110,8 +110,8 @@ const labels = computed(() => ({
class="ui centered image"
>
<img
v-else-if="track.artist?.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
v-else-if="!!track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="getArtistCoverUrl(track.artist_credit)"
alt=""
class="ui centered image"
>
@ -126,14 +126,14 @@ const labels = computed(() => ({
{{ track.title }}
</h3>
<h4 class="track-modal-subtitle">
{{ track.artist?.name }}
{{ generateTrackCreditString(track) }}
</h4>
</div>
<div class="ui hidden divider" />
<div class="content">
<div class="ui one column unstackable grid">
<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"
>
<div
@ -213,13 +213,16 @@ const labels = computed(() => ({
class="row"
>
<div
v-for="ac in track.artist_credit"
:key="ac.artist.id"
class="column"
role="button"
: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" />
<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 class="row">
@ -235,7 +238,7 @@ const labels = computed(() => ({
</div>
<div class="ui divider" />
<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"
class="row"
@click.stop.prevent="report(obj)"

View File

@ -1,5 +1,5 @@
<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 { 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
tracks: Track[]
isPlayable?: boolean
artist?: Artist | null
artistCredit?: ArtistCredit[] | null
album?: Album | null
playlist?: Playlist | null
library?: Library | null
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
showPosition: false,
displayActions: true,
artist: null,
artistCredit: null,
album: null,
playlist: null,
library: null,
@ -130,8 +130,8 @@ const hover = ref(false)
class="ui artist-track mini image"
>
<img
v-else-if="track.artist?.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) "
v-else-if="track.artist_credit?.length && track.artist_credit[0].artist.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist_credit[0].artist.cover.urls.medium_square_crop) "
alt=""
class="ui artist-track mini image"
>
@ -166,15 +166,21 @@ const hover = ref(false)
v-if="showArtist"
class="content ellipsis left floated column"
>
<router-link
class="artist link"
:to="{
name: 'library.artists.detail',
params: { id: track.artist?.id },
}"
<template
v-for="ac in track.artist_credit"
:key="ac.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
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 { getArtistCoverUrl } from '~/utils/utils'
interface Events {
(e: 'count', count: number): void
}
@ -117,8 +119,8 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
alt=""
>
<img
v-else-if="object.track.artist?.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.artist.cover.urls.medium_square_crop)"
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 0"
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
alt=""
>
<img
@ -142,16 +144,20 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
</router-link>
</div>
<div
v-if="object.track.artist"
v-if="object.track.artist_credit"
class="meta ellipsis"
>
<span>
<span
v-for="ac in object.track.artist_credit"
:key="ac.artist.id"
>
<router-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>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</span>
</div>
<tags-list

View File

@ -1,5 +1,5 @@
<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 { computed, reactive, ref, watch } from 'vue'
@ -9,7 +9,7 @@ import { sum } from 'lodash-es'
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 TagsList from '~/components/tags/List.vue'
import AlbumDropdown from './AlbumDropdown.vue'
@ -30,13 +30,21 @@ const props = defineProps<Props>()
const object = ref<Album | null>(null)
const artist = ref<Artist | null>(null)
const artistCredit = ref([] as ArtistCredit[])
const libraries = ref([] as Library[])
const paginateBy = ref(50)
const totalTracks = computed(() => object.value?.tracks_count ?? 0)
const isChannel = computed(() => !!object.value?.artist.channel)
const isAlbum = computed(() => object.value?.artist.content_category === 'music')
const isSerie = computed(() => object.value?.artist.content_category === 'podcast')
const isChannel = computed(() => {
return artistCredit.value.some(ac => ac.artist.channel)
})
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 publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
@ -52,7 +60,10 @@ const fetchData = async () => {
isLoading.value = 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
if (artist.value?.channel) {
@ -201,7 +212,7 @@ const remove = async () => {
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"
:artist-credit="artistCredit"
@remove="remove"
/>
</div>
@ -214,9 +225,9 @@ const remove = async () => {
>
{{ object.title }}
</h2>
<artist-label
v-if="artist"
:artist="artist"
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
</header>
</div>
@ -244,10 +255,9 @@ const remove = async () => {
>
{{ object.title }}
</h2>
<artist-label
v-if="artist"
:artist="artist"
class="rounded"
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
</header>
<div
@ -285,7 +295,7 @@ const remove = async () => {
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"
:artist-credit="artistCredit"
@remove="remove"
/>
<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"
:total-tracks="totalTracks"
:is-serie="isSerie"
:artist="artist"
:artist-credit="artistCredit"
:object="object"
:is-loading-tracks="isLoadingTracks"
object-type="album"

View File

@ -1,5 +1,5 @@
<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 ChannelEntries from '~/components/audio/ChannelEntries.vue'
@ -17,7 +17,7 @@ interface Props {
isLoadingTracks: boolean
isSerie: boolean
artist: Artist
artistCredit: ArtistCredit[]
paginateBy: number
totalTracks: number
}
@ -66,10 +66,10 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</h2>
<channel-entries
v-if="artist.channel && isSerie"
v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
:is-podcast="isSerie"
: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>
@ -123,7 +123,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</div>
</template>
<template v-if="!artist.channel && !isSerie">
<template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
<h2>
{{ $t('components.library.AlbumDetail.header.libraries') }}
</h2>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Album, Artist, Library } from '~/types'
import type { Album, ArtistCredit, Library } from '~/types'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@ -17,7 +17,7 @@ interface Events {
interface Props {
isLoading: boolean
artist: Artist | null
artistCredit: ArtistCredit[]
object: Album
publicLibraries: Library[]
isAlbum: boolean
@ -38,9 +38,9 @@ const labels = computed(() => ({
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 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')
</script>
@ -125,7 +125,7 @@ const remove = () => emit('remove')
{{ $t('components.library.AlbumDropdown.button.edit') }}
</router-link>
<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']"
@confirm="remove()"
>
@ -151,7 +151,7 @@ const remove = () => emit('remove')
</dangerous-button>
<div class="divider" />
<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"
role="button"
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 isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length)
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 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 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_credit?.[0].artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`)
const downloadUrl = computed(() => {
const url = store.getters['instance/absoluteUrl'](upload.value?.listen_url ?? '')
return store.state.auth.authenticated

View File

@ -76,7 +76,7 @@ watchEffect(() => {
class="ui fluid image track-cover-image"
>
<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') }}
</span>
<span v-else>
@ -169,14 +169,24 @@ watchEffect(() => {
{{ $t('components.library.TrackDetail.table.release.artist') }}
</td>
<td class="right aligned">
<router-link :to="{name: 'library.artists.detail', params: {id: track.artist?.id}}">
{{ track.artist?.name }}
</router-link>
<template
v-for="ac in track.artist_credit"
: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>
</tr>
<tr v-if="track.album">
<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') }}
</span>
<span v-else>

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