Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
Show changes
Showing
with 1579 additions and 488 deletions
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from funkwhale_api.music import models, tasks
......@@ -103,11 +101,9 @@ class Command(BaseCommand):
pruned_total = prunable.count()
total = models.Track.objects.count()
if options["dry_run"]:
self.stdout.write(
"Would prune {}/{} tracks".format(pruned_total, total)
)
self.stdout.write(f"Would prune {pruned_total}/{total} tracks")
else:
self.stdout.write("Deleting {}/{} tracks…".format(pruned_total, total))
self.stdout.write(f"Deleting {pruned_total}/{total} tracks…")
prunable.delete()
if options["prune_albums"]:
......@@ -115,11 +111,9 @@ class Command(BaseCommand):
pruned_total = prunable.count()
total = models.Album.objects.count()
if options["dry_run"]:
self.stdout.write(
"Would prune {}/{} albums".format(pruned_total, total)
)
self.stdout.write(f"Would prune {pruned_total}/{total} albums")
else:
self.stdout.write("Deleting {}/{} albums…".format(pruned_total, total))
self.stdout.write(f"Deleting {pruned_total}/{total} albums…")
prunable.delete()
if options["prune_artists"]:
......@@ -127,11 +121,9 @@ class Command(BaseCommand):
pruned_total = prunable.count()
total = models.Artist.objects.count()
if options["dry_run"]:
self.stdout.write(
"Would prune {}/{} artists".format(pruned_total, total)
)
self.stdout.write(f"Would prune {pruned_total}/{total} artists")
else:
self.stdout.write("Deleting {}/{} artists…".format(pruned_total, total))
self.stdout.write(f"Deleting {pruned_total}/{total} artists…")
prunable.delete()
self.stdout.write("")
......
from django.core.management.base import BaseCommand
from django.db import transaction
from funkwhale_api.music import models
class Command(BaseCommand):
help = """Deletes any tracks not tagged with a MusicBrainz ID from the database. By default, any tracks that
have been favorited by a user or added to a playlist are preserved."""
def add_arguments(self, parser):
parser.add_argument(
"--no-dry-run",
action="store_true",
dest="no_dry_run",
default=True,
help="Disable dry run mode and apply pruning for real on the database",
)
parser.add_argument(
"--include-playlist-content",
action="store_true",
dest="include_playlist_content",
default=False,
help="Allow tracks included in playlists to be pruned",
)
parser.add_argument(
"--include-favorites-content",
action="store_true",
dest="include_favorited_content",
default=False,
help="Allow favorited tracks to be pruned",
)
parser.add_argument(
"--include-listened-content",
action="store_true",
dest="include_listened_content",
default=False,
help="Allow tracks with listening history to be pruned",
)
@transaction.atomic
def handle(self, *args, **options):
tracks = models.Track.objects.filter(mbid__isnull=True)
if not options["include_favorited_content"]:
tracks = tracks.filter(track_favorites__isnull=True)
if not options["include_playlist_content"]:
tracks = tracks.filter(playlist_tracks__isnull=True)
if not options["include_listened_content"]:
tracks = tracks.filter(listenings__isnull=True)
pruned_total = tracks.count()
total = models.Track.objects.count()
if options["no_dry_run"]:
self.stdout.write(f"Deleting {pruned_total}/{total} tracks…")
tracks.delete()
else:
self.stdout.write(f"Would prune {pruned_total}/{total} tracks")
from django.core.management.base import BaseCommand
from django.db import transaction
from funkwhale_api.music import models
class Command(BaseCommand):
help = """
This command makes it easy to prune all skipped Uploads from the database.
Due to a bug they might caused the database to grow exponentially,
especially when using in-place-imports on a regular basis. This command
helps to clean up the database again.
"""
def add_arguments(self, parser):
parser.add_argument(
"--force",
default=False,
help="Disable dry run mode and apply pruning for real on the database",
)
@transaction.atomic
def handle(self, *args, **options):
skipped = models.Upload.objects.filter(import_status="skipped")
count = skipped.count()
if options["force"]:
skipped.delete()
print(f"Deleted {count} entries from the database.")
return
print(
f"Would delete {count} entries from the database.\
Run with --force to actually apply changes to the database"
)
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from funkwhale_api.music.models import TrackActor, Library
from funkwhale_api.federation.models import Actor
from funkwhale_api.music.models import Library, TrackActor
class Command(BaseCommand):
......@@ -26,34 +24,44 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"username",
nargs='*',
nargs="*",
help="Rebuild only for given users",
)
@transaction.atomic
def handle(self, *args, **options):
actor_ids = []
if options['username']:
if options["username"]:
actors = Actor.objects.all().local(True)
actor_ids = list(actors.filter(preferred_username__in=options['username']).values_list('id', flat=True))
if len(actor_ids) < len(options['username']):
raise CommandError('Invalid username')
print('Emptying permission table for specified users…')
qs = TrackActor.objects.all().filter(Q(actor__pk__in=actor_ids) | Q(actor=None))
actor_ids = list(
actors.filter(preferred_username__in=options["username"]).values_list(
"id", flat=True
)
)
if len(actor_ids) < len(options["username"]):
raise CommandError("Invalid username")
print("Emptying permission table for specified users…")
qs = TrackActor.objects.all().filter(
Q(actor__pk__in=actor_ids) | Q(actor=None)
)
qs._raw_delete(qs.db)
else:
print('Emptying permission table…')
print("Emptying permission table…")
qs = TrackActor.objects.all()
qs._raw_delete(qs.db)
libraries = Library.objects.all()
objs = []
total_libraries = len(libraries)
for i, library in enumerate(libraries):
print('[{}/{}] Populating permission table for library {}'.format(i + 1, total_libraries, library.pk))
print(
"[{}/{}] Populating permission table for library {}".format(
i + 1, total_libraries, library.pk
)
)
objs += TrackActor.get_objs(
library=library,
actor_ids=actor_ids,
upload_and_track_ids=[],
)
print('Commiting changes…')
print("Committing changes…")
TrackActor.objects.bulk_create(objs, batch_size=5000, ignore_conflicts=True)
import base64
from collections.abc import Mapping
import datetime
import logging
import arrow
from collections.abc import Mapping
import arrow
import magic
import mutagen._util
import mutagen.flac
import mutagen.oggtheora
import mutagen.oggvorbis
import mutagen.flac
from rest_framework import serializers
from funkwhale_api.tags import models as tags_models
logger = logging.getLogger(__name__)
NODEFAULT = object()
# default title used when imported tracks miss the `Album` tag, see #122
......@@ -134,10 +132,31 @@ def clean_flac_pictures(apic):
return pictures
def clean_ogg_pictures(metadata_block_picture):
def clean_ogg_coverart(metadata_block_picture):
pictures = []
for b64_data in [metadata_block_picture]:
try:
data = base64.b64decode(b64_data)
except (TypeError, ValueError):
continue
mime = magic.Magic(mime=True)
mime.from_buffer(data)
pictures.append(
{
"mimetype": mime.from_buffer(data),
"content": data,
"description": "",
"type": mutagen.id3.PictureType.COVER_FRONT,
}
)
return pictures
def clean_ogg_pictures(metadata_block_picture):
pictures = []
for b64_data in [metadata_block_picture]:
try:
data = base64.b64decode(b64_data)
except (TypeError, ValueError):
......@@ -200,10 +219,16 @@ CONF = {
"license": {},
"copyright": {},
"genre": {},
"pictures": {
"pictures": [
{
"field": "metadata_block_picture",
"to_application": clean_ogg_pictures,
},
{
"field": "coverart",
"to_application": clean_ogg_coverart,
},
],
"comment": {"field": "comment"},
},
},
......@@ -225,10 +250,16 @@ CONF = {
"license": {},
"copyright": {},
"genre": {},
"pictures": {
"pictures": [
{
"field": "metadata_block_picture",
"to_application": clean_ogg_pictures,
},
{
"field": "coverart",
"to_application": clean_ogg_coverart,
},
],
"comment": {"field": "comment"},
},
},
......@@ -356,13 +387,15 @@ class Metadata(Mapping):
def __init__(self, filething, kind=mutagen.File):
self._file = kind(filething)
if self._file is None:
raise ValueError("Cannot parse metadata from {}".format(filething))
raise ValueError(f"Cannot parse metadata from {filething}")
if len(self._file) == 0:
raise ValueError(f"No tags found in {filething}")
self.fallback = self.load_fallback(filething, self._file)
ft = self.get_file_type(self._file)
try:
self._conf = CONF[ft]
except KeyError:
raise ValueError("Unsupported format {}".format(ft))
raise ValueError(f"Unsupported format {ft}")
def get_file_type(self, f):
return f.__class__.__name__
......@@ -417,17 +450,19 @@ class Metadata(Mapping):
def _get_from_self(self, key, default=NODEFAULT):
try:
field_conf = self._conf["fields"][key]
field_confs = self._conf["fields"][key]
except KeyError:
raise UnsupportedTag("{} is not supported for this file format".format(key))
raise UnsupportedTag(f"{key} is not supported for this file format")
if not isinstance(field_confs, list):
field_confs = [field_confs]
for field_conf in field_confs:
real_key = field_conf.get("field", key)
try:
getter = field_conf.get("getter", self._conf["getter"])
v = getter(self._file, real_key)
except KeyError:
if default == NODEFAULT:
raise TagNotFound(real_key)
return default
continue
converter = field_conf.get("to_application")
if converter:
......@@ -436,6 +471,9 @@ class Metadata(Mapping):
if field:
v = field.to_python(v)
return v
if default == NODEFAULT:
raise TagNotFound(real_key)
return default
def get_picture(self, *picture_types):
if not picture_types:
......@@ -466,8 +504,7 @@ class Metadata(Mapping):
return 1
def __iter__(self):
for field in self._conf["fields"]:
yield field
yield from self._conf["fields"]
class ArtistField(serializers.Field):
......@@ -478,8 +515,8 @@ class ArtistField(serializers.Field):
def get_value(self, data):
if self.for_album:
keys = [
("artists", "artists"),
("names", "album_artist"),
("artists", "album_artist"),
("names", "artists"),
("mbids", "musicbrainz_albumartistid"),
]
else:
......@@ -495,54 +532,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
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)
# now, we split on artist names, using the same separator as the one used
# by mbids, if any
names = []
return final_artist_credits
if data.get("artists", None):
for separator in separators:
if separator in data["artists"]:
names = [n.strip() for n in data["artists"].split(separator)]
break
if 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"]]
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):
......@@ -561,16 +620,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
......@@ -612,38 +672,15 @@ class PermissiveDateField(serializers.CharField):
def extract_tags_from_genre(string):
tags = []
delimiter = "@@@@@"
for d in [" - ", ",", ";", "/"]:
for d in [" - ", ", ", ",", "; ", ";", "/"]:
# Replace common tags separators by a custom delimiter
string = string.replace(d, delimiter)
# loop on the parts (splitting on our custom delimiter)
for tag in string.split(delimiter):
tag = tag.strip()
for d in ["-"]:
# preparation for replacement so that Pop-Rock becomes Pop Rock, then PopRock
# (step 1, step 2 happens below)
tag = tag.replace(d, " ")
if not tag:
continue
final_tag = ""
if not tags_models.TAG_REGEX.match(tag.replace(" ", "")):
# the string contains some non words chars ($, €, etc.), right now
# we simply skip such tags
continue
# concatenate the parts and uppercase them so that 'pop rock' becomes 'PopRock'
if len(tag.split(" ")) == 1:
# we append the tag "as is", because it doesn't contain any space
tags.append(tag)
continue
for part in tag.split(" "):
# the tag contains space, there's work to do to have consistent case
# 'pop rock' -> 'PopRock'
# (step 2)
if not part:
continue
final_tag += part[0].upper() + part[1:]
if final_tag:
tags.append(final_tag)
return tags
......@@ -674,14 +711,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
......@@ -750,8 +790,8 @@ class TrackMetadataSerializer(serializers.Serializer):
description = DescriptionField(allow_null=True, allow_blank=True, required=False)
album = AlbumField()
artists = ArtistField()
cover_data = CoverDataField()
artist_credit = ArtistField()
cover_data = CoverDataField(required=False)
remove_blank_null_fields = [
"copyright",
......
# Generated by Django 3.2.4 on 2021-07-03 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0053_denormalize_audio_permissions'),
]
operations = [
migrations.AlterField(
model_name='uploadversion',
name='mimetype',
field=models.CharField(choices=[('audio/mp3', 'mp3'), ('audio/mpeg3', 'mp3'), ('audio/x-mp3', 'mp3'), ('audio/mpeg', 'mp3'), ('video/ogg', 'ogg'), ('audio/ogg', 'ogg'), ('audio/opus', 'opus'), ('audio/x-m4a', 'aac'), ('audio/x-m4a', 'm4a'), ('audio/x-flac', 'flac'), ('audio/flac', 'flac'), ('audio/aiff', 'aif'), ('audio/x-aiff', 'aif'), ('audio/aiff', 'aiff'), ('audio/x-aiff', 'aiff')], max_length=50),
),
]
# Generated by Django 3.2.13 on 2022-06-27 19:15
import django.core.serializers.json
from django.db import migrations, models
import funkwhale_api.music.models
class Migration(migrations.Migration):
dependencies = [
('music', '0054_alter_uploadversion_mimetype'),
]
operations = [
migrations.AlterField(
model_name='upload',
name='import_details',
field=models.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='upload',
name='import_metadata',
field=models.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
migrations.AlterField(
model_name='upload',
name='metadata',
field=models.JSONField(blank=True, default=funkwhale_api.music.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
),
]
# Generated by Django 3.2.14 on 2022-07-19 13:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0055_auto_20220627_1915'),
]
operations = [
migrations.AlterField(
model_name='artist',
name='content_category',
field=models.CharField(choices=[('music', 'music'), ('podcast', 'podcast'), ('other', 'other')], db_index=True, default='music', max_length=30),
),
]
# Generated by Django 3.2.16 on 2022-11-18 21:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0056_alter_artist_content_category'),
]
operations = [
migrations.AlterField(
model_name='album',
name='title',
field=models.TextField(),
),
migrations.AlterField(
model_name='artist',
name='name',
field=models.TextField(),
),
migrations.AlterField(
model_name='track',
name='copyright',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='track',
name='title',
field=models.TextField(),
),
]
# Generated by Django 3.2.23 on 2024-01-30 11:58
import itertools
from django.db import migrations, models
from django.db.models import Q
from funkwhale_api.music import utils
def set_quality_upload(apps, schema_editor):
Upload = apps.get_model("music", "Upload")
extension_to_mimetypes = utils.get_extension_to_mimetype_dict()
# Low quality
mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=192)
OpusAACOGG_query = Q(
mimetype__in=list(
itertools.chain(
extension_to_mimetypes["opus"],
extension_to_mimetypes["ogg"],
extension_to_mimetypes["aac"],
)
)
) & Q(bitrate__lte=96)
low = Upload.objects.filter((mp3_query) | (OpusAACOGG_query))
low.update(quality=0)
# medium
mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=256)
ogg_query = Q(mimetype__in=extension_to_mimetypes["ogg"]) & Q(bitrate__lte=192)
aacopus_query = Q(
mimetype__in=list(
itertools.chain(
extension_to_mimetypes["aac"], extension_to_mimetypes["opus"]
)
)
) & Q(bitrate__lte=128)
medium = Upload.objects.filter((mp3_query) | (ogg_query) | (aacopus_query)).exclude(
pk__in=low.values_list("pk", flat=True)
)
medium.update(quality=1)
# high
mp3_query = Q(mimetype__in=extension_to_mimetypes["mp3"]) & Q(bitrate__lte=320)
ogg_query = Q(mimetype__in=extension_to_mimetypes["ogg"]) & Q(bitrate__lte=256)
aac_query = Q(mimetype__in=extension_to_mimetypes["aac"]) & Q(bitrate__lte=288)
opus_query = Q(mimetype__in=extension_to_mimetypes["opus"]) & Q(bitrate__lte=160)
high = (
Upload.objects.filter((mp3_query) | (ogg_query) | (aac_query) | (opus_query))
.exclude(pk__in=low.values_list("pk", flat=True))
.exclude(pk__in=medium.values_list("pk", flat=True))
)
high.update(quality=2)
# veryhigh
opus_query = Q(mimetype__in=extension_to_mimetypes["opus"]) & Q(bitrate__gte=510)
flacaifaiff_query = Q(
mimetype__in=list(
itertools.chain(
extension_to_mimetypes["flac"],
extension_to_mimetypes["aif"],
extension_to_mimetypes["aiff"],
)
)
)
Upload.objects.filter((opus_query) | (flacaifaiff_query)).exclude(
pk__in=low.values_list("pk", flat=True)
).exclude(pk__in=medium.values_list("pk", flat=True)).exclude(
pk__in=high.values_list("pk", flat=True)
).update(
quality=3
)
def skip(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("music", "0057_auto_20221118_2108"),
]
operations = [
migrations.AddField(
model_name="upload",
name="quality",
field=models.IntegerField(
choices=[(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")],
default=1,
),
),
migrations.RunPython(set_quality_upload, skip),
]
# Generated by Django 4.2.9 on 2024-03-16 00:36
import django.contrib.postgres.search
from django.db import migrations, models, connection
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,
)
return (obj.pk, artist_credit.pk)
def bulk_save_m2m(model, relations, obj_name):
table = model.artist_credit.through._meta.db_table
values_sql = ", ".join(["(%s, %s)"] * len(relations))
params = [x for pair in relations for x in pair]
query = f"""
INSERT INTO {table} ({obj_name}_id, artistcredit_id)
VALUES {values_sql}
ON CONFLICT DO NOTHING
"""
with connection.cursor() as cursor:
cursor.execute(query, params)
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")
relations = []
for track in Track.objects.all():
relations.append(save_artist_credit(track, ArtistCredit))
if relations:
bulk_save_m2m(Track, relations, "track")
relations = []
for album in Album.objects.all():
relations.append(save_artist_credit(album, ArtistCredit))
if relations:
bulk_save_m2m(Album, relations, "album")
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",
),
]
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("music", "0059_remove_album_artist_remove_track_artist_artistcredit_and_more"),
("playlists", "0007_alter_playlist_actor_alter_playlisttrack_uuid_and_more"),
]
operations = []
# Generated by Django 4.2.9 on 2025-01-03 16:12
from django.db import migrations, models, transaction
from django.conf import settings
from funkwhale_api.federation import utils as federation_utils
from django.urls import reverse
import uuid
def insert_tracks_to_playlist(apps, playlist, uploads):
PlaylistTrack = apps.get_model("playlists", "PlaylistTrack")
uploads_to_update = []
for i, upload in enumerate(uploads):
if upload.track:
PlaylistTrack.objects.create(
creation_date=playlist.creation_date,
playlist=playlist,
track=upload.track,
index=0 + i,
uuid=(new_uuid := uuid.uuid4()),
fid=federation_utils.full_url(
reverse(
"federation:music:playlist-tracks-detail",
kwargs={"uuid": new_uuid},
)
),
)
upload.library = None
uploads_to_update.append(upload)
apps.get_model("music", "Upload").objects.bulk_update(
uploads_to_update, fields=["library"], batch_size=1000
)
playlist.library.playlist_uploads.set(uploads)
@transaction.atomic
def migrate_libraries_to_playlist(apps, schema_editor):
Playlist = apps.get_model("playlists", "Playlist")
Library = apps.get_model("music", "Library")
Actor = apps.get_model("federation", "Actor")
Channel = apps.get_model("audio", "Channel")
to_instance_libs = []
to_public_libs = []
to_me_libs = []
for library in Library.objects.all():
if (
not federation_utils.is_local(library.actor.fid)
or library.actor.name == "service"
):
continue
if (
hasattr(library, "playlist")
and library.playlist
and library.uploads.all().exists()
):
uploads = library.uploads.all()
with transaction.atomic():
insert_tracks_to_playlist(apps, library.playlist, uploads)
continue
if (
Channel.objects.filter(library=library).exists()
or Playlist.objects.filter(library=library).exists()
or not federation_utils.is_local(library.fid)
or library.name in ["me", "instance", "everyone", "followers"]
):
continue
try:
playlist, created = Playlist.objects.get_or_create(
name=library.name,
library=library,
actor=library.actor,
creation_date=library.creation_date,
privacy_level=library.privacy_level,
description=library.description,
defaults={
"uuid": (new_uuid := uuid.uuid4()),
"fid": federation_utils.full_url(
reverse(
"federation:music:playlists-detail",
kwargs={"uuid": new_uuid},
)
),
},
)
playlist.save()
if library.uploads.all().exists():
uploads = library.uploads.all()
with transaction.atomic():
insert_tracks_to_playlist(apps, playlist, uploads)
if library.privacy_level == "me":
to_me_libs.append(library)
if library.privacy_level == "instance":
to_instance_libs.append(library)
if library.privacy_level == "everyone":
to_public_libs.append(library)
library.privacy_level = "me"
library.playlist = playlist
library.save()
except Exception as e:
print(f"An error occurred during library.playlist creation : {e}")
continue
# migrate uploads to new built-in libraries
for actor in Actor.objects.all():
if (
not federation_utils.is_local(actor.fid)
or actor.name == "service"
or hasattr(actor, "channel")
):
continue
privacy_levels = ["me", "instance", "followers", "everyone"]
for privacy_level in privacy_levels:
build_in_lib, created = Library.objects.filter(
channel__isnull=True
).get_or_create(
actor=actor,
privacy_level=privacy_level,
name=privacy_level,
defaults={
"uuid": (new_uuid := uuid.uuid4()),
"fid": federation_utils.full_url(
reverse(
"federation:music:libraries-detail",
kwargs={"uuid": new_uuid},
)
),
},
)
for library in actor.libraries.filter(privacy_level=privacy_level):
library.uploads.all().update(library=build_in_lib)
library.delete
if privacy_level == "everyone":
for lib in to_public_libs:
lib.uploads.all().update(library=build_in_lib)
if privacy_level == "instance":
for lib in to_instance_libs:
lib.uploads.all().update(library=build_in_lib)
if privacy_level == "me":
for lib in to_me_libs:
lib.uploads.all().update(library=build_in_lib)
def check_succefull_migration(apps, schema_editor):
Actor = apps.get_model("federation", "Actor")
Playlist = apps.get_model("playlists", "Playlist")
for actor in Actor.objects.all():
not_build_in_libs = (
actor.playlists.count()
+ actor.libraries.filter(channel__isnull=False).count()
)
if actor.name == "service" or not federation_utils.is_local(actor.fid):
continue
elif actor.playlists.filter(library__isnull=True).count() > 0:
raise Exception(
f"Incoherent playlist database state : all local playlists do not have lib or too many libs"
)
elif (
not hasattr(actor, "channel")
and actor.libraries.count() - 4 != not_build_in_libs
or (hasattr(actor, "channel") and actor.libraries.count() > 1)
):
raise Exception(
f"Incoherent library database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss.\
actor libs = {actor.libraries.count()} and acto not built-in lib = {not_build_in_libs} \
and acto pl ={actor.playlists.count()} and not channel lib = {actor.libraries.filter(channel__isnull=False).count()} \
and actor.name = {actor.name}"
)
for playlist in Playlist.objects.all():
if not federation_utils.is_local(playlist.fid):
continue
elif playlist.library.privacy_level != "me":
raise Exception(
"Incoherent playlist database state, check for errors in log and share them to the funkwhale team. Migration was abordted to prevent data loss"
)
class Migration(migrations.Migration):
dependencies = [
("music", "0060_empty_for_test"),
("playlists", "0009_playlist_library"),
]
operations = [
migrations.AlterField(
model_name="library",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
default="me",
max_length=25,
),
),
migrations.AddField(
model_name="upload",
name="playlist_libraries",
field=models.ManyToManyField(
blank=True,
related_name="playlist_uploads",
to="music.library",
),
),
migrations.RunPython(
migrate_libraries_to_playlist, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
check_succefull_migration, reverse_code=migrations.RunPython.noop
),
]
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("music", "0061_migrate_libraries_to_playlist"),
]
operations = [
migrations.RemoveField(
model_name="library",
name="description",
),
migrations.RemoveField(
model_name="library",
name="followers_url",
),
]
# Generated by Django 4.2.9 on 2024-12-21 20:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0062_del_lib_description"),
]
operations = [
migrations.AddField(
model_name="upload",
name="third_party_provider",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name="uploadversion",
name="mimetype",
field=models.CharField(
choices=[
("audio/mp3", "mp3"),
("audio/mpeg3", "mp3"),
("audio/x-mp3", "mp3"),
("audio/mpeg", "mp3"),
("video/ogg", "ogg"),
("audio/ogg", "ogg"),
("audio/opus", "opus"),
("audio/x-m4a", "aac"),
("audio/x-m4a", "m4a"),
("audio/m4a", "m4a"),
("audio/x-flac", "flac"),
("audio/flac", "flac"),
("audio/aiff", "aif"),
("audio/x-aiff", "aif"),
("audio/aiff", "aiff"),
("audio/x-aiff", "aiff"),
],
max_length=50,
),
),
]
import datetime
import itertools
import logging
import os
import tempfile
import urllib.parse
import uuid
from random import randint
import arrow
import pydub
import slugify
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models, transaction
from django.db.models import Count, JSONField, Max, Min, Prefetch
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.query_utils import Q
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from django.db.models import Prefetch, Count
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
from funkwhale_api.tags import models as tags_models
from . import importers, metadata, utils
logger = logging.getLogger(__name__)
MAX_LENGTHS = {
"ARTIST_NAME": 255,
"ALBUM_TITLE": 255,
"TRACK_TITLE": 255,
"COPYRIGHT": 500,
}
ARTIST_CONTENT_CATEGORY_CHOICES = [
("music", "music"),
("podcast", "podcast"),
......@@ -91,9 +87,7 @@ class APIModelMixin(models.Model):
cls.musicbrainz_model
]
else:
raw_data = cls.api.search(**kwargs)[
"{0}-list".format(cls.musicbrainz_model)
][0]
raw_data = cls.api.search(**kwargs)[f"{cls.musicbrainz_model}-list"][0]
cleaned_data = cls.clean_musicbrainz_data(raw_data)
return importers.load(cls, cleaned_data, raw_data, cls.import_hooks)
......@@ -119,10 +113,9 @@ class APIModelMixin(models.Model):
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
"federation:music:{}-detail".format(self.federation_namespace),
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
......@@ -134,7 +127,7 @@ class APIModelMixin(models.Model):
return super().save(**kwargs)
@property
def is_local(self):
def is_local(self) -> bool:
return federation_utils.is_local(self.fid)
@property
......@@ -178,12 +171,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"
),
......@@ -193,7 +186,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]
)
......@@ -202,7 +195,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:
......@@ -210,7 +205,7 @@ class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
class Artist(APIModelMixin):
name = models.CharField(max_length=MAX_LENGTHS["ARTIST_NAME"])
name = models.TextField()
federation_namespace = "artists"
musicbrainz_model = "artist"
musicbrainz_mapping = {
......@@ -248,7 +243,7 @@ class Artist(APIModelMixin):
db_index=True,
default="music",
choices=ARTIST_CONTENT_CATEGORY_CHOICES,
null=True,
null=False,
)
modification_date = models.DateTimeField(default=timezone.now, db_index=True)
api = musicbrainz.api.artists
......@@ -258,10 +253,10 @@ class Artist(APIModelMixin):
return self.name
def get_absolute_url(self):
return "/library/artists/{}".format(self.pk)
return f"/library/artists/{self.pk}"
def get_moderation_url(self):
return "/manage/library/artists/{}".format(self.pk)
return f"/manage/library/artists/{self.pk}"
@classmethod
def get_or_create_from_name(cls, name, **kwargs):
......@@ -279,9 +274,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):
......@@ -297,6 +308,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"))
......@@ -304,7 +348,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]
)
......@@ -319,10 +363,24 @@ class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
else:
return self.exclude(pk__in=matches)
def with_duration(self):
# takes one upload per track
subquery = Subquery(
Upload.objects.filter(track_id=OuterRef("tracks"))
.order_by("id")
.values("id")[:1]
)
return self.annotate(
duration=models.Sum(
"tracks__uploads__duration",
filter=Q(tracks__uploads=subquery),
)
)
class Album(APIModelMixin):
title = models.CharField(max_length=MAX_LENGTHS["ALBUM_TITLE"])
artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
title = models.TextField()
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(
......@@ -373,9 +431,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()
......@@ -388,16 +446,23 @@ class Album(APIModelMixin):
return self.title
def get_absolute_url(self):
return "/library/albums/{}".format(self.pk)
return f"/library/albums/{self.pk}"
def get_moderation_url(self):
return "/manage/library/albums/{}".format(self.pk)
return f"/manage/library/albums/{self.pk}"
@classmethod
def get_or_create_from_title(cls, title, **kwargs):
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
......@@ -421,17 +486,16 @@ 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")),
),
)
def annotate_playable_by_actor(self, actor):
files = (
Upload.objects.playable_by(actor)
.filter(track=models.OuterRef("id"))
......@@ -442,7 +506,6 @@ class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
return self.annotate(is_playable_by_actor=subquery)
def playable_by(self, actor, include=True):
if settings.MUSIC_USE_DENORMALIZATION:
if actor is not None:
query = models.Q(actor=None) | models.Q(actor=actor)
......@@ -470,6 +533,26 @@ class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
"""
return self.order_by("disc_number", "position", "title")
def random(self, batch_size):
bounds = self.aggregate(min_id=Min("id"), max_id=Max("id"))
min_id, max_id = bounds["min_id"], bounds["max_id"]
if min_id is None or max_id is None:
return self.none()
tries = 0
max_tries = 10
found_ids = set()
while len(found_ids) < batch_size and tries < max_tries:
candidate_ids = [randint(min_id, max_id) for _ in range(batch_size * 2)]
found_ids.update(
self.filter(id__in=candidate_ids).values_list("id", flat=True)
)
tries += 1
return self.filter(id__in=list(found_ids)[:batch_size]).order_by("?")
def get_artist(release_list):
return Artist.get_or_create_from_api(
......@@ -479,8 +562,8 @@ def get_artist(release_list):
class Track(APIModelMixin):
mbid = models.UUIDField(db_index=True, null=True, blank=True)
title = models.CharField(max_length=MAX_LENGTHS["TRACK_TITLE"])
artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE)
title = models.TextField()
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(
......@@ -503,9 +586,7 @@ class Track(APIModelMixin):
on_delete=models.SET_NULL,
related_name="attributed_tracks",
)
copyright = models.CharField(
max_length=MAX_LENGTHS["COPYRIGHT"], null=True, blank=True
)
copyright = models.TextField(null=True, blank=True)
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
......@@ -524,11 +605,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},
}
......@@ -551,24 +630,26 @@ class Track(APIModelMixin):
return self.title
def get_absolute_url(self):
return "/library/tracks/{}".format(self.pk)
return f"/library/tracks/{self.pk}"
def get_moderation_url(self):
return "/manage/library/tracks/{}".format(self.pk)
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 "{} - {} - {}".format(self.artist.name, self.album.title, self.title)
return (
f"{self.get_artist_credit_string} - {self.album.title} - {self.title}"
)
except AttributeError:
return "{} - {}".format(self.artist.name, self.title)
return f"{self.get_artist_credit_string} - {self.title}"
@property
def cover(self):
......@@ -576,14 +657,17 @@ class Track(APIModelMixin):
def get_activity_url(self):
if self.mbid:
return "https://musicbrainz.org/recording/{}".format(self.mbid)
return settings.FUNKWHALE_URL + "/tracks/{}".format(self.pk)
return f"https://musicbrainz.org/recording/{self.mbid}"
return settings.FUNKWHALE_URL + f"/tracks/{self.pk}"
@classmethod
def get_or_create_from_title(cls, title, **kwargs):
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)
......@@ -606,38 +690,47 @@ 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
if "joinphrase" not in ac:
joinphrase = ""
else:
track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0]
return cls.objects.update_or_create(
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):
def listen_url(self) -> str:
# Not using reverse because this is slow
return "/api/v1/listen/{}/".format(self.uuid)
return f"/api/v2/listen/{self.uuid}/"
@property
def local_license(self):
......@@ -662,10 +755,15 @@ class UploadQuerySet(common_models.NullsLastQuerySet):
def playable_by(self, actor, include=True):
libraries = Library.objects.viewable_by(actor)
if include:
return self.filter(library__in=libraries, import_status="finished")
return self.exclude(library__in=libraries, import_status="finished")
return self.filter(
Q(library__in=libraries) | Q(playlist_libraries__in=libraries),
import_status__in=["finished", "skipped"],
)
return self.exclude(
Q(library__in=libraries) | Q(playlist_libraries__in=libraries),
import_status__in=["finished", "skipped"],
)
def local(self, include=True):
query = models.Q(library__actor__domain_id=settings.FEDERATION_HOSTNAME)
......@@ -690,11 +788,16 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = (
def get_file_path(instance, filename):
# Convert unicode characters in name to ASCII characters.
filename = slugify.slugify(filename, ok=slugify.SLUG_OK + ".", only_ascii=True)
if isinstance(instance, UploadVersion):
return common_utils.ChunkedPath("transcoded")(instance, filename)
if instance.library.actor.get_user():
return common_utils.ChunkedPath("tracks")(instance, filename)
elif instance.third_party_provider:
return common_utils.ChunkedPath("third_party_tracks")(instance, filename)
else:
# we cache remote tracks in a different directory
return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename)
......@@ -704,6 +807,9 @@ def get_import_reference():
return str(uuid.uuid4())
quality_choices = [(0, "low"), (1, "medium"), (2, "high"), (3, "very_high")]
class Upload(models.Model):
fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
......@@ -732,13 +838,18 @@ class Upload(models.Model):
related_name="uploads",
on_delete=models.CASCADE,
)
playlist_libraries = models.ManyToManyField(
"library",
blank=True,
related_name="playlist_uploads",
)
# metadata from federation
metadata = JSONField(
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
)
import_date = models.DateTimeField(null=True, blank=True)
# optionnal metadata provided during import
# optional metadata provided during import
import_metadata = JSONField(
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
)
......@@ -750,7 +861,7 @@ class Upload(models.Model):
# in the same import
import_reference = models.CharField(max_length=50, default=get_import_reference)
# optionnal metadata about import results (error messages, etc.)
# optional metadata about import results (error messages, etc.)
import_details = JSONField(
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
)
......@@ -762,10 +873,14 @@ class Upload(models.Model):
# stores checksums such as `sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
checksum = models.CharField(max_length=100, db_index=True, null=True, blank=True)
quality = models.IntegerField(choices=quality_choices, default=1)
third_party_provider = models.CharField(max_length=100, null=True, blank=True)
objects = UploadQuerySet.as_manager()
@property
def is_local(self):
def is_local(self) -> bool:
return federation_utils.is_local(self.fid)
@property
......@@ -798,10 +913,10 @@ 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 = "{}.{}".format(title, extension)
filename = f"{title}.{extension}"
tmp_file = tempfile.TemporaryFile()
for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk)
......@@ -817,8 +932,8 @@ class Upload(models.Model):
)
@property
def filename(self):
return "{}.{}".format(self.track.full_name, self.extension)
def filename(self) -> str:
return f"{self.track.full_name}.{self.extension}"
@property
def extension(self):
......@@ -844,6 +959,12 @@ class Upload(models.Model):
if self.source and self.source.startswith("file://"):
return open(self.source.replace("file://", "", 1), "rb")
def get_audio_file_path(self):
if self.audio_file:
return self.audio_file.path
if self.source and self.source.startswith("file://"):
return self.source.replace("file://", "", 1)
def get_audio_data(self):
audio_file = self.get_audio_file()
if not audio_file:
......@@ -857,13 +978,49 @@ class Upload(models.Model):
"size": self.get_file_size(),
}
def get_audio_segment(self):
input = self.get_audio_file()
if not input:
return
def get_quality(self):
extension_to_mimetypes = utils.get_extension_to_mimetype_dict()
if not self.bitrate and self.mimetype not in list(
itertools.chain(
extension_to_mimetypes["aiff"],
extension_to_mimetypes["aif"],
extension_to_mimetypes["flac"],
)
):
return 1
bitrate_limits = {
"mp3": {192: 0, 256: 1, 320: 2},
"ogg": {96: 0, 192: 1, 256: 2},
"aac": {96: 0, 128: 1, 288: 2},
"m4a": {96: 0, 128: 1, 288: 2},
"opus": {
96: 0,
128: 1,
160: 2,
},
}
audio = pydub.AudioSegment.from_file(input)
return audio
for ext in bitrate_limits:
if self.mimetype in extension_to_mimetypes[ext]:
for limit, quality in sorted(bitrate_limits[ext].items()):
if int(self.bitrate) <= limit:
return quality
# opus higher tham 160
return 3
if self.mimetype in list(
itertools.chain(
extension_to_mimetypes["aiff"],
extension_to_mimetypes["aif"],
extension_to_mimetypes["flac"],
)
):
return 3
else:
return 1
def save(self, **kwargs):
if not self.mimetype:
......@@ -884,6 +1041,7 @@ class Upload(models.Model):
if not self.pk and not self.fid and self.library.actor.get_user():
self.fid = self.get_federation_id()
self.quality = self.get_quality()
return super().save(**kwargs)
def get_metadata(self):
......@@ -893,13 +1051,13 @@ class Upload(models.Model):
return metadata.Metadata(audio_file)
@property
def listen_url(self):
return self.track.listen_url + "?upload={}".format(self.uuid)
def listen_url(self) -> str:
return self.track.listen_url + f"?upload={self.uuid}"
def get_listen_url(self, to=None, download=True):
def get_listen_url(self, to=None, download=True) -> str:
url = self.listen_url
if to:
url += "&to={}".format(to)
url += f"&to={to}"
if not download:
url += "&download=false"
......@@ -940,13 +1098,13 @@ class Upload(models.Model):
bitrate = min(bitrate or 320000, self.bitrate or 320000)
version = self.versions.create(mimetype=mimetype, bitrate=bitrate, size=0)
# we keep the same name, but we update the extension
new_name = os.path.splitext(os.path.basename(self.audio_file.name))[
0
] + ".{}".format(format)
new_name = (
os.path.splitext(os.path.basename(self.audio_file.name))[0] + f".{format}"
)
version.audio_file.save(new_name, f)
utils.transcode_audio(
audio=self.get_audio_segment(),
output=version.audio_file,
audio_file_path=self.get_audio_file_path(),
output_path=version.audio_file.path,
output_format=utils.MIMETYPE_TO_EXTENSION[mimetype],
bitrate=str(bitrate),
)
......@@ -978,9 +1136,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
......@@ -1002,7 +1172,7 @@ class UploadVersion(models.Model):
unique_together = ("upload", "mimetype", "bitrate")
@property
def filename(self):
def filename(self) -> str:
try:
return (
self.upload.track.full_name
......@@ -1085,9 +1255,7 @@ class ImportBatch(models.Model):
tasks.import_batch_notify_followers.delay(import_batch_id=self.pk)
def get_federation_id(self):
return federation_utils.full_url(
"/federation/music/import/batch/{}".format(self.uuid)
)
return federation_utils.full_url(f"/federation/music/import/batch/{self.uuid}")
class ImportJob(models.Model):
......@@ -1127,11 +1295,6 @@ class ImportJob(models.Model):
return super().save(**kwargs)
LIBRARY_PRIVACY_LEVEL_CHOICES = [
(k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]
class LibraryQuerySet(models.QuerySet):
def local(self, include=True):
query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME)
......@@ -1149,27 +1312,54 @@ class LibraryQuerySet(models.QuerySet):
)
def viewable_by(self, actor):
from funkwhale_api.federation.models import LibraryFollow, Follow
from funkwhale_api.federation.models import Follow, LibraryFollow
if actor is None:
return self.filter(privacy_level="everyone")
me_query = models.Q(privacy_level="me", actor=actor)
me_query = models.Q(privacy_level__in=["me", "followers"], actor=actor)
instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
followed_libraries = LibraryFollow.objects.filter(
actor=actor, approved=True
).values_list("target", flat=True)
followed_channels_libraries = (
Follow.objects.exclude(target__channel=None)
.filter(actor=actor, approved=True,)
.filter(
actor=actor,
approved=True,
)
.values_list("target__channel__library", flat=True)
)
domains_reachable = federation_models.Domain.objects.filter(
reachable=True
) | federation_models.Domain.objects.filter(name=settings.FUNKWHALE_HOSTNAME)
# User follow
followed_actors = Follow.objects.filter(actor=actor, approved=True).values_list(
"target", flat=True
)
# service actor can access libraries if there is approved followers on their manage domain
if actor.managed_domains.exists():
remote_service_actors = Q(
privacy_level="followers",
received_follows__approved=True,
received_follows__actor__domain__in=actor.managed_domains.all(),
) | Q(
privacy_level="followers",
actor__received_follows__approved=True,
actor__received_follows__actor__domain__in=actor.managed_domains.all(),
)
else:
remote_service_actors = Q()
return self.filter(
me_query
| instance_query
| remote_service_actors
| models.Q(privacy_level="everyone")
| models.Q(pk__in=followed_libraries)
| models.Q(pk__in=followed_channels_libraries)
| models.Q(actor__in=followed_actors, privacy_level="followers")
& models.Q(actor__domain__in=domains_reachable)
)
......@@ -1178,12 +1368,10 @@ class Library(federation_models.FederationMixin):
actor = models.ForeignKey(
"federation.Actor", related_name="libraries", on_delete=models.CASCADE
)
followers_url = models.URLField(max_length=500)
creation_date = models.DateTimeField(default=timezone.now)
name = models.CharField(max_length=100)
description = models.TextField(max_length=5000, null=True, blank=True)
privacy_level = models.CharField(
choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25
choices=fields.PRIVACY_LEVEL_CHOICES, default="me", max_length=25
)
uploads_count = models.PositiveIntegerField(default=0)
objects = LibraryQuerySet.as_manager()
......@@ -1191,16 +1379,16 @@ class Library(federation_models.FederationMixin):
def __str__(self):
return self.name
def get_moderation_url(self):
return "/manage/library/libraries/{}".format(self.uuid)
def get_moderation_url(self) -> str:
return f"/manage/library/libraries/{self.uuid}"
def get_federation_id(self):
def get_federation_id(self) -> str:
return federation_utils.full_url(
reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
)
def get_absolute_url(self):
return "/library/{}".format(self.uuid)
def get_absolute_url(self) -> str:
return f"/library/{self.uuid}"
def save(self, **kwargs):
if not self.pk and not self.fid and self.actor.is_local:
......@@ -1209,7 +1397,7 @@ class Library(federation_models.FederationMixin):
return super().save(**kwargs)
def should_autoapprove_follow(self, actor):
def should_autoapprove_follow(self, actor) -> bool:
if self.privacy_level == "everyone":
return True
if self.privacy_level == "instance" and actor.get_user():
......@@ -1241,6 +1429,9 @@ class Library(federation_models.FederationMixin):
except ObjectDoesNotExist:
return None
def latest_scan(self):
return self.scans.order_by("-creation_date").first()
SCAN_STATUS = [
("pending", "pending"),
......@@ -1314,7 +1505,24 @@ class TrackActor(models.Model):
objs.append(
cls(actor_id=actor_id, track_id=track_id, upload_id=upload_id)
)
elif library.privacy_level == "followers":
follow_queryset = library.actor.received_follows
follow_queryset = follow_queryset.filter(approved=True).exclude(
actor__user__isnull=True
)
if actor_ids:
follow_queryset = follow_queryset.filter(actor__pk__in=actor_ids)
owner = library.actor if library.actor.is_local else None
final_actor_ids = list(follow_queryset.values_list("actor", flat=True))
if owner and (not actor_ids or owner in final_actor_ids):
final_actor_ids.append(owner.pk)
final_actor_ids = list(follow_queryset.values_list("actor", flat=True))
for actor_id in final_actor_ids:
for upload_id, track_id in upload_and_track_ids:
objs.append(
cls(actor_id=actor_id, track_id=track_id, upload_id=upload_id)
)
elif library.privacy_level == "instance":
for upload_id, track_id in upload_and_track_ids:
objs.append(
......@@ -1358,7 +1566,13 @@ def update_batch_status(sender, instance, **kwargs):
@receiver(post_save, sender=Upload)
def update_denormalization_track_actor(sender, instance, created, **kwargs):
if (
(
created
or (
kwargs.get("update_fields", None)
and "library" in kwargs.get("update_fields")
)
)
and settings.MUSIC_USE_DENORMALIZATION
and instance.track_id
and instance.import_status == "finished"
......
......@@ -2,7 +2,6 @@ from funkwhale_api.common import models as common_models
from funkwhale_api.common import mutations
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
from funkwhale_api.tags import models as tags_models
from funkwhale_api.tags import serializers as tags_serializers
......@@ -127,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:
......
import os
import pathlib
import urllib.parse
from django.db import transaction
from django import urls
from django.conf import settings
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.federation.serializers import APIActorSerializer
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tag_models
from funkwhale_api.tags import serializers as tags_serializers
......@@ -29,8 +35,6 @@ COVER_WRITE_FIELD = common_serializers.RelatedField(
write_only=True,
)
from funkwhale_api.audio import serializers as audio_serializers # NOQA
class CoverField(common_serializers.AttachmentSerializer):
pass
......@@ -39,17 +43,7 @@ class CoverField(common_serializers.AttachmentSerializer):
cover_field = CoverField()
def serialize_attributed_to(self, obj):
# Import at runtime to avoid a circular import issue
from funkwhale_api.federation import serializers as federation_serializers
if not obj.attributed_to_id:
return
return federation_serializers.APIActorSerializer(obj.attributed_to).data
class OptionalDescriptionMixin(object):
class OptionalDescriptionMixin:
def to_representation(self, obj):
repr = super().to_representation(obj)
if self.context.get("description", False):
......@@ -74,7 +68,7 @@ class LicenseSerializer(serializers.Serializer):
attribution = serializers.BooleanField()
copyleft = serializers.BooleanField()
def get_id(self, obj):
def get_id(self, obj) -> str:
return obj["identifiers"][0]
class Meta:
......@@ -83,24 +77,24 @@ class LicenseSerializer(serializers.Serializer):
class ArtistAlbumSerializer(serializers.Serializer):
tracks_count = serializers.SerializerMethodField()
cover = cover_field
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
is_local = serializers.BooleanField()
id = serializers.IntegerField()
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):
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):
def get_tracks_count(self, o) -> int:
return len(o.tracks.all())
def get_is_playable(self, obj):
def get_is_playable(self, obj) -> bool:
try:
return bool(obj.is_playable_by_actor)
except AttributeError:
......@@ -110,11 +104,22 @@ class ArtistAlbumSerializer(serializers.Serializer):
DATETIME_FIELD = serializers.DateTimeField()
class InlineActorSerializer(serializers.Serializer):
full_username = serializers.CharField()
preferred_username = serializers.CharField()
domain = serializers.CharField(source="domain_id")
class ArtistWithAlbumsInlineChannelSerializer(serializers.Serializer):
uuid = serializers.CharField()
actor = InlineActorSerializer()
class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer):
albums = ArtistAlbumSerializer(many=True)
albums = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
attributed_to = serializers.SerializerMethodField()
channel = serializers.SerializerMethodField()
attributed_to = APIActorSerializer(allow_null=True)
channel = ArtistWithAlbumsInlineChannelSerializer(allow_null=True)
tracks_count = serializers.SerializerMethodField()
id = serializers.IntegerField()
fid = serializers.URLField()
......@@ -123,113 +128,65 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize
content_category = serializers.CharField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
cover = cover_field
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]
get_attributed_to = serialize_attributed_to
def get_tracks_count(self, o) -> int:
return getattr(o, "_tracks_count", 0)
def get_tracks_count(self, o):
tracks = getattr(o, "_prefetched_tracks", None)
return len(tracks) if tracks else None
def get_channel(self, o):
channel = o.get_channel()
if not channel:
return
return {
"uuid": str(channel.uuid),
"actor": {
"full_username": channel.actor.full_username,
"preferred_username": channel.actor.preferred_username,
"domain": channel.actor.domain_id,
},
}
def serialize_artist_simple(artist):
data = {
"id": artist.id,
"fid": artist.fid,
"mbid": str(artist.mbid),
"name": artist.name,
"creation_date": DATETIME_FIELD.to_representation(artist.creation_date),
"modification_date": DATETIME_FIELD.to_representation(artist.modification_date),
"is_local": artist.is_local,
"content_category": artist.content_category,
}
if "description" in artist._state.fields_cache:
data["description"] = (
common_serializers.ContentSerializer(artist.description).data
if artist.description
else None
)
if "attachment_cover" in artist._state.fields_cache:
data["cover"] = (
cover_field.to_representation(artist.attachment_cover)
if artist.attachment_cover
else None
)
if "channel" in artist._state.fields_cache and artist.get_channel():
data["channel"] = str(artist.channel.uuid)
if getattr(artist, "_tracks_count", None) is not None:
data["tracks_count"] = artist._tracks_count
if getattr(artist, "_prefetched_tagged_items", None) is not None:
data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items]
return data
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist = serializers.SerializerMethodField()
cover = cover_field
is_playable = serializers.SerializerMethodField()
class ArtistSerializer(serializers.ModelSerializer):
cover = CoverField(allow_null=True, required=False)
description = common_serializers.ContentSerializer(allow_null=True, required=False)
channel = serializers.UUIDField(allow_null=True, required=False)
tags = serializers.SerializerMethodField()
tracks_count = serializers.SerializerMethodField()
attributed_to = serializers.SerializerMethodField()
id = serializers.IntegerField()
fid = serializers.URLField()
mbid = serializers.UUIDField()
title = serializers.CharField()
artist = serializers.SerializerMethodField()
release_date = serializers.DateField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
is_playable = serializers.SerializerMethodField()
get_attributed_to = serialize_attributed_to
def get_artist(self, o):
return serialize_artist_simple(o.artist)
def get_tracks_count(self, o):
return len(o.tracks.all())
def get_is_playable(self, obj):
try:
return any(
[bool(getattr(t, "playable_uploads", [])) for t in obj.tracks.all()]
class Meta:
model = models.Artist
fields = (
"id",
"fid",
"mbid",
"name",
"creation_date",
"modification_date",
"is_local",
"content_category",
"description",
"cover",
"channel",
"attributed_to",
"tags",
)
except AttributeError:
return None
@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]
class ArtistCreditSerializer(serializers.ModelSerializer):
artist = ArtistSerializer()
class Meta:
model = models.ArtistCredit
fields = ["artist", "credit", "joinphrase", "index"]
class TrackAlbumSerializer(serializers.ModelSerializer):
artist = serializers.SerializerMethodField()
cover = cover_field
artist_credit = ArtistCreditSerializer(many=True)
cover = CoverField(allow_null=True)
tracks_count = serializers.SerializerMethodField()
def get_tracks_count(self, o):
def get_tracks_count(self, o) -> int:
return getattr(o, "_prefetched_tracks_count", len(o.tracks.all()))
class Meta:
......@@ -239,7 +196,7 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
"fid",
"mbid",
"title",
"artist",
"artist_credit",
"release_date",
"cover",
"creation_date",
......@@ -247,21 +204,19 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
"tracks_count",
)
def get_artist(self, o):
return serialize_artist_simple(o.artist)
class TrackUploadSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
listen_url = serializers.URLField()
size = serializers.IntegerField()
duration = serializers.IntegerField()
bitrate = serializers.IntegerField()
mimetype = serializers.CharField()
extension = serializers.CharField()
is_local = serializers.SerializerMethodField()
def serialize_upload(upload):
return {
"uuid": str(upload.uuid),
"listen_url": upload.listen_url,
"size": upload.size,
"duration": upload.duration,
"bitrate": upload.bitrate,
"mimetype": upload.mimetype,
"extension": upload.extension,
"is_local": federation_utils.is_local(upload.fid),
}
def get_is_local(self, upload) -> bool:
return federation_utils.is_local(upload.fid)
def sort_uploads_for_listen(uploads):
......@@ -281,18 +236,17 @@ def sort_uploads_for_listen(uploads):
class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist = serializers.SerializerMethodField()
artist_credit = ArtistCreditSerializer(many=True)
album = TrackAlbumSerializer(read_only=True)
uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
attributed_to = serializers.SerializerMethodField()
attributed_to = APIActorSerializer(allow_null=True)
description = common_serializers.ContentSerializer(allow_null=True, required=False)
id = serializers.IntegerField()
fid = serializers.URLField()
mbid = serializers.UUIDField()
title = serializers.CharField()
artist = serializers.SerializerMethodField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
position = serializers.IntegerField()
......@@ -300,29 +254,80 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
downloads_count = serializers.IntegerField()
copyright = serializers.CharField()
license = serializers.SerializerMethodField()
cover = cover_field
get_attributed_to = serialize_attributed_to
def get_artist(self, o):
return serialize_artist_simple(o.artist)
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
@extend_schema_field(OpenApiTypes.URI)
def get_listen_url(self, obj):
return obj.listen_url
# @extend_schema_field({"type": "array", "items": {"type": "object"}})
@extend_schema_field(TrackUploadSerializer(many=True))
def get_uploads(self, obj):
uploads = getattr(obj, "playable_uploads", [])
# we put local uploads first
uploads = [serialize_upload(u) for u in sort_uploads_for_listen(uploads)]
uploads = [
TrackUploadSerializer(u).data for u in sort_uploads_for_listen(uploads)
]
uploads = sorted(uploads, key=lambda u: u["is_local"], reverse=True)
return list(uploads)
@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_license(self, o):
def get_license(self, o) -> str:
return o.license_id
def get_is_playable(self, obj) -> bool:
return bool(getattr(obj, "playable_uploads", []))
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist_credit = ArtistCreditSerializer(many=True)
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
tracks_count = serializers.SerializerMethodField()
attributed_to = APIActorSerializer()
id = serializers.IntegerField()
fid = serializers.URLField()
mbid = serializers.UUIDField()
title = serializers.CharField()
release_date = serializers.DateField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
duration = serializers.SerializerMethodField(read_only=True)
tracks = TrackSerializer(many=True, allow_null=True)
description = common_serializers.ContentSerializer(allow_null=True, required=False)
def get_tracks_count(self, o) -> int:
return len(o.tracks.all())
def get_is_playable(self, obj) -> bool:
try:
return any(
[
bool(getattr(t, "is_playable_by_actor", None))
for t in obj.tracks.all()
]
)
except AttributeError:
return None
@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_duration(self, obj) -> int:
try:
return obj.duration
except AttributeError:
# no annotation?
return 0
@common_serializers.track_fields_for_update("name", "description", "privacy_level")
class LibraryForOwnerSerializer(serializers.ModelSerializer):
......@@ -336,7 +341,6 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer):
"uuid",
"fid",
"name",
"description",
"privacy_level",
"uploads_count",
"size",
......@@ -345,10 +349,10 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer):
]
read_only_fields = ["fid", "uuid", "creation_date", "actor"]
def get_uploads_count(self, o):
return getattr(o, "_uploads_count", o.uploads_count)
def get_uploads_count(self, o) -> int:
return getattr(o, "_uploads_count", int(o.uploads_count))
def get_size(self, o):
def get_size(self, o) -> int:
return getattr(o, "_size", 0)
def on_updated_fields(self, obj, before, after):
......@@ -356,14 +360,14 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer):
{"type": "Update", "object": {"type": "Library"}}, context={"library": obj}
)
@extend_schema_field(APIActorSerializer)
def get_actor(self, o):
# Import at runtime to avoid a circular import issue
from funkwhale_api.federation import serializers as federation_serializers
return federation_serializers.APIActorSerializer(o.actor).data
return APIActorSerializer(o.actor).data
class UploadSerializer(serializers.ModelSerializer):
from funkwhale_api.audio.serializers import ChannelSerializer
track = TrackSerializer(required=False, allow_null=True)
library = common_serializers.RelatedField(
"uuid",
......@@ -371,9 +375,12 @@ class UploadSerializer(serializers.ModelSerializer):
required=False,
filters=lambda context: {"actor": context["user"].actor},
)
privacy_level = serializers.ChoiceField(
choices=fields.PRIVACY_LEVEL_CHOICES, required=False
)
channel = common_serializers.RelatedField(
"uuid",
audio_serializers.ChannelSerializer(),
ChannelSerializer(),
required=False,
filters=lambda context: {"attributed_to": context["user"].actor},
)
......@@ -394,6 +401,7 @@ class UploadSerializer(serializers.ModelSerializer):
"size",
"import_date",
"import_status",
"privacy_level",
]
read_only_fields = [
......@@ -419,9 +427,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()
......@@ -458,6 +466,7 @@ class ImportMetadataSerializer(serializers.Serializer):
)
@extend_schema_field(ImportMetadataSerializer)
class ImportMetadataField(serializers.JSONField):
def to_internal_value(self, v):
v = super().to_internal_value(v)
......@@ -470,9 +479,10 @@ class ImportMetadataField(serializers.JSONField):
class UploadForOwnerSerializer(UploadSerializer):
import_status = serializers.ChoiceField(
choices=["draft", "pending"], default="pending"
choices=models.TRACK_FILE_IMPORT_STATUS_CHOICES, default="pending"
)
import_metadata = ImportMetadataField(required=False)
filename = serializers.CharField(required=False)
class Meta(UploadSerializer.Meta):
fields = UploadSerializer.Meta.fields + [
......@@ -483,7 +493,7 @@ class UploadForOwnerSerializer(UploadSerializer):
"source",
"audio_file",
]
write_only_fields = ["audio_file"]
extra_kwargs = {"audio_file": {"write_only": True}}
read_only_fields = UploadSerializer.Meta.read_only_fields + [
"import_details",
"metadata",
......@@ -493,6 +503,7 @@ class UploadForOwnerSerializer(UploadSerializer):
r = super().to_representation(obj)
if "audio_file" in r:
del r["audio_file"]
r["privacy_level"] = obj.library.privacy_level
return r
def validate(self, validated_data):
......@@ -517,6 +528,13 @@ class UploadForOwnerSerializer(UploadSerializer):
if "channel" in validated_data:
validated_data["library"] = validated_data.pop("channel").library
if "import_status" in validated_data and validated_data[
"import_status"
] not in ["draft", "pending"]:
raise serializers.ValidationError(
"Newly created Uploads need to have import_status of draft or pending"
)
return super().validate(validated_data)
def validate_upload_quota(self, f):
......@@ -527,6 +545,46 @@ class UploadForOwnerSerializer(UploadSerializer):
return f
class UploadBulkUpdateListSerializer(serializers.ListSerializer):
def create(self, validated_data):
privacy_levels = ["me", "instance", "followers", "everyone"]
privacy_level_map = {
privacy_level: self.context["actor"]
.libraries.filter(privacy_level=privacy_level, name=privacy_level)
.exclude(playlist__isnull=False)
.first()
for privacy_level in privacy_levels
}
if None in privacy_level_map.values():
raise federation_utils.BuiltInLibException(
{"details": "Built-in library not found or too many"}
)
objs = []
for data in validated_data:
try:
uuid = data.get("uuid", None)
upload = models.Upload.objects.select_related("track").get(uuid=uuid)
except models.Upload.DoesNotExist:
raise serializers.ValidationError(
f"Upload with uuid {uuid} does not exist"
)
upload.library = privacy_level_map[data["privacy_level"]]
# bulk_update skip post-save signal (raw sql db query), we need the to update the denormalization table
# could optimize and work on a bulk denormalization table update. In the meantime we do it one by one
upload.save(update_fields=["library"])
objs.append(upload)
return objs
class UploadBulkUpdateSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
privacy_level = serializers.ChoiceField(choices=fields.PRIVACY_LEVEL_CHOICES)
class Meta:
list_serializer_class = UploadBulkUpdateListSerializer
class UploadActionSerializer(common_serializers.ActionSerializer):
actions = [
common_serializers.Action("delete", allow_all=True),
......@@ -567,14 +625,8 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = tag_models.Tag
fields = ("id", "name", "creation_date")
class SimpleAlbumSerializer(serializers.ModelSerializer):
cover = cover_field
cover = CoverField(allow_null=True)
class Meta:
model = models.Album
......@@ -584,12 +636,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"
......@@ -600,7 +652,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
def get_embed_url(type, id):
return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id)
return settings.FUNKWHALE_EMBED_URL + f"?type={type}&id={id}"
class OembedSerializer(serializers.Serializer):
......@@ -629,9 +681,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:
......@@ -640,7 +692,7 @@ class OembedSerializer(serializers.Serializer):
)
embed_type = "track"
embed_id = track.pk
data["title"] = "{} by {}".format(track.title, track.artist.name)
data["title"] = f"{track.title} by {track.get_artist_credit_string}"
if track.attachment_cover:
data[
"thumbnail_url"
......@@ -654,15 +706,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:
......@@ -679,15 +733,17 @@ class OembedSerializer(serializers.Serializer):
] = album.attachment_cover.download_url_medium_square_crop
data["thumbnail_width"] = 200
data["thumbnail_height"] = 200
data["title"] = "{} by {}".format(album.title, album.artist.name)
data["description"] = "{} by {}".format(album.title, 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:
......@@ -698,7 +754,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[
......@@ -808,17 +874,20 @@ 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(
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():
......@@ -827,13 +896,12 @@ class AlbumCreateSerializer(serializers.Serializer):
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"),
......@@ -842,7 +910,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
......@@ -850,11 +919,20 @@ class FSImportSerializer(serializers.Serializer):
path = serializers.CharField(allow_blank=True)
library = serializers.UUIDField()
import_reference = serializers.CharField()
prune = serializers.BooleanField(required=False, default=True)
outbox = serializers.BooleanField(required=False, default=False)
broadcast = serializers.BooleanField(required=False, default=False)
replace = serializers.BooleanField(required=False, default=False)
batch_size = serializers.IntegerField(required=False, default=1000)
verbosity = serializers.IntegerField(required=False, default=1)
def validate_path(self, value):
try:
utils.browse_dir(settings.MUSIC_DIRECTORY_PATH, value)
except (NotADirectoryError, FileNotFoundError, ValueError):
except NotADirectoryError:
if not os.path.isfile(pathlib.Path(settings.MUSIC_DIRECTORY_PATH) / value):
raise serializers.ValidationError("Invalid path")
except (FileNotFoundError, ValueError):
raise serializers.ValidationError("Invalid path")
return value
......@@ -864,3 +942,10 @@ class FSImportSerializer(serializers.Serializer):
return self.context["user"].actor.libraries.get(uuid=value)
except models.Library.DoesNotExist:
raise serializers.ValidationError("Invalid library")
class SearchResultSerializer(serializers.Serializer):
artists = ArtistWithAlbumsSerializer(many=True)
tracks = TrackSerializer(many=True)
albums = AlbumSerializer(many=True)
tags = tags_serializers.TagSerializer(many=True)
import django.dispatch
upload_import_status_updated = django.dispatch.Signal(
providing_args=["old_status", "new_status", "upload"]
)
""" Required args: old_status, new_status, upload """
upload_import_status_updated = django.dispatch.Signal()
import urllib.parse
from django.conf import settings
from django.urls import reverse
from django.db.models import Q
from django.urls import reverse
from funkwhale_api.common import preferences
from funkwhale_api.common import middleware
from funkwhale_api.common import utils
from funkwhale_api.common import middleware, preferences, utils
from funkwhale_api.playlists import models as playlists_models
from . import models
from . import serializers
from . import models, serializers
def get_twitter_card_metas(type, id):
......@@ -27,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:
......@@ -50,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},
]
# 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": obj.artist.pk}),
utils.spa_reverse("library_artist", kwargs={"pk": ac.artist.pk}),
),
},
]
}
)
if obj.album:
metas.append(
......@@ -112,7 +115,7 @@ def library_track(request, pk, redirect_to_ap):
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?format=json&url={}".format(urllib.parse.quote_plus(track_url))
+ f"?format=json&url={urllib.parse.quote_plus(track_url)}"
),
}
)
......@@ -122,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:
......@@ -139,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"},
]
# 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": obj.artist.pk}),
utils.spa_reverse("library_artist", kwargs={"pk": ac.artist.pk}),
),
},
]
}
)
if obj.release_date:
metas.append(
{
......@@ -184,7 +191,7 @@ def library_album(request, pk, redirect_to_ap):
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?format=json&url={}".format(urllib.parse.quote_plus(album_url))
+ f"?format=json&url={urllib.parse.quote_plus(album_url)}"
),
}
)
......@@ -209,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},
......@@ -237,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()
):
......@@ -248,7 +261,7 @@ def library_artist(request, pk, redirect_to_ap):
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?format=json&url={}".format(urllib.parse.quote_plus(artist_url))
+ f"?format=json&url={urllib.parse.quote_plus(artist_url)}"
),
}
)
......@@ -300,7 +313,7 @@ def library_playlist(request, pk, redirect_to_ap):
"type": "application/json+oembed",
"href": (
utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
+ "?format=json&url={}".format(urllib.parse.quote_plus(obj_url))
+ f"?format=json&url={urllib.parse.quote_plus(obj_url)}"
),
}
)
......@@ -327,7 +340,6 @@ def library_library(request, uuid, redirect_to_ap):
{"tag": "meta", "property": "og:url", "content": library_url},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": obj.name},
{"tag": "meta", "property": "og:description", "content": obj.description},
]
if preferences.get("federation__enabled"):
......