From 1bee3a4675a63156eee4f56e8a63be4acd308d53 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Sun, 23 Sep 2018 12:38:42 +0000 Subject: [PATCH] Import trust source --- api/funkwhale_api/federation/serializers.py | 90 ++-- api/funkwhale_api/music/metadata.py | 57 ++- api/funkwhale_api/music/models.py | 33 +- api/funkwhale_api/music/tasks.py | 459 +++++++++++------- .../management/commands/import_files.py | 47 +- api/requirements/local.txt | 1 + api/tests/conftest.py | 9 +- api/tests/federation/test_serializers.py | 128 +---- api/tests/music/test.mp3 | Bin 297745 -> 297745 bytes api/tests/music/test_metadata.py | 62 ++- api/tests/music/test_tasks.py | 351 +++++++++++--- api/tests/test_import_audio_file.py | 35 +- dev.yml | 1 + .../views/content/libraries/FilesTable.vue | 4 + 14 files changed, 860 insertions(+), 417 deletions(-) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 99ed708f19..71cd7a8314 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -4,7 +4,6 @@ import urllib.parse from django.core.exceptions import ObjectDoesNotExist from django.core.paginator import Paginator -from django.db.models import F, Q from rest_framework import serializers from funkwhale_api.common import utils as funkwhale_utils @@ -21,6 +20,31 @@ AP_CONTEXT = [ logger = logging.getLogger(__name__) +class LinkSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["Link"]) + href = serializers.URLField(max_length=500) + mediaType = serializers.CharField() + + def __init__(self, *args, **kwargs): + self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) + super().__init__(*args, **kwargs) + + def validate_mediaType(self, v): + if not self.allowed_mimetypes: + # no restrictions + return v + for mt in self.allowed_mimetypes: + if mt.endswith("/*"): + if v.startswith(mt.replace("*", "")): + return v + else: + if v == mt: + return v + raise serializers.ValidationError( + "Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes) + ) + + class ActorSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) outbox = serializers.URLField(max_length=500) @@ -626,32 +650,8 @@ class MusicEntitySerializer(serializers.Serializer): musicbrainzId = serializers.UUIDField(allow_null=True, required=False) name = serializers.CharField(max_length=1000) - def create(self, validated_data): - mbid = validated_data.get("musicbrainzId") - candidates = self.model.objects.filter( - Q(mbid=mbid) | Q(fid=validated_data["id"]) - ).order_by(F("fid").desc(nulls_last=True)) - - existing = candidates.first() - if existing: - return existing - - # nothing matching in our database, let's create a new object - return self.model.objects.create(**self.get_create_data(validated_data)) - - def get_create_data(self, validated_data): - return { - "mbid": validated_data.get("musicbrainzId"), - "fid": validated_data["id"], - "name": validated_data["name"], - "creation_date": validated_data["published"], - "from_activity": self.context.get("activity"), - } - class ArtistSerializer(MusicEntitySerializer): - model = music_models.Artist - def to_representation(self, instance): d = { "type": "Artist", @@ -667,9 +667,11 @@ class ArtistSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer): - model = music_models.Album released = serializers.DateField(allow_null=True, required=False) artists = serializers.ListField(child=ArtistSerializer(), min_length=1) + cover = LinkSerializer( + allowed_mimetypes=["image/*"], allow_null=True, required=False + ) def to_representation(self, instance): d = { @@ -688,7 +690,12 @@ class AlbumSerializer(MusicEntitySerializer): ], } if instance.cover: - d["cover"] = {"type": "Image", "url": utils.full_url(instance.cover.url)} + d["cover"] = { + "type": "Link", + "href": utils.full_url(instance.cover.url), + "mediaType": mimetypes.guess_type(instance.cover.path)[0] + or "image/jpeg", + } if self.context.get("include_ap_context", self.parent is None): d["@context"] = AP_CONTEXT return d @@ -711,7 +718,6 @@ class AlbumSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer): - model = music_models.Track position = serializers.IntegerField(min_value=0, allow_null=True, required=False) artists = serializers.ListField(child=ArtistSerializer(), min_length=1) album = AlbumSerializer() @@ -738,32 +744,22 @@ class TrackSerializer(MusicEntitySerializer): d["@context"] = AP_CONTEXT return d - def get_create_data(self, validated_data): - artist_data = validated_data["artists"][0] - artist = ArtistSerializer( - context={"activity": self.context.get("activity")} - ).create(artist_data) - album = AlbumSerializer( - context={"activity": self.context.get("activity")} - ).create(validated_data["album"]) + def create(self, validated_data): + from funkwhale_api.music import tasks as music_tasks - return { - "mbid": validated_data.get("musicbrainzId"), - "fid": validated_data["id"], - "title": validated_data["name"], - "position": validated_data.get("position"), - "creation_date": validated_data["published"], - "artist": artist, - "album": album, - "from_activity": self.context.get("activity"), - } + metadata = music_tasks.federation_audio_track_to_metadata(validated_data) + from_activity = self.context.get("activity") + if from_activity: + metadata["from_activity_id"] = from_activity.pk + track = music_tasks.get_track_from_import_metadata(metadata) + return track class UploadSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["Audio"]) id = serializers.URLField(max_length=500) library = serializers.URLField(max_length=500) - url = serializers.JSONField() + url = LinkSerializer(allowed_mimetypes=["audio/*"]) published = serializers.DateTimeField() updated = serializers.DateTimeField(required=False, allow_null=True) bitrate = serializers.IntegerField(min_value=0) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 4c754ae056..21daf2747c 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -93,9 +93,9 @@ def convert_track_number(v): class FirstUUIDField(forms.UUIDField): def to_python(self, value): try: - # sometimes, Picard leaves to uuids in the field, separated - # by a slash - value = value.split("/")[0] + # sometimes, Picard leaves two uuids in the field, separated + # by a slash or a ; + value = value.split(";")[0].split("/")[0].strip() except (AttributeError, IndexError, TypeError): pass @@ -107,10 +107,18 @@ def get_date(value): return datetime.date(parsed.year, parsed.month, parsed.day) +def split_and_return_first(separator): + def inner(v): + return v.split(separator)[0].strip() + + return inner + + VALIDATION = { "musicbrainz_artistid": FirstUUIDField(), "musicbrainz_albumid": FirstUUIDField(), "musicbrainz_recordingid": FirstUUIDField(), + "musicbrainz_albumartistid": FirstUUIDField(), } CONF = { @@ -123,10 +131,15 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": { + "field": "albumartist", + "to_application": split_and_return_first(";"), + }, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, }, }, @@ -139,10 +152,15 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": { + "field": "albumartist", + "to_application": split_and_return_first(";"), + }, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, }, }, @@ -155,10 +173,12 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": {"field": "albumartist"}, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"}, "musicbrainz_recordingid": {"field": "MusicBrainz Track Id"}, }, }, @@ -169,10 +189,12 @@ CONF = { "track_number": {"field": "TRCK", "to_application": convert_track_number}, "title": {"field": "TIT2"}, "artist": {"field": "TPE1"}, + "album_artist": {"field": "TPE2"}, "album": {"field": "TALB"}, "date": {"field": "TDRC", "to_application": get_date}, "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_albumartistid": {"field": "MusicBrainz Album Artist Id"}, "musicbrainz_recordingid": { "field": "UFID", "getter": get_mp3_recording_id, @@ -190,10 +212,12 @@ CONF = { }, "title": {}, "artist": {}, + "album_artist": {"field": "albumartist"}, "album": {}, "date": {"field": "date", "to_application": get_date}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, + "musicbrainz_albumartistid": {}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, "test": {}, "pictures": {}, @@ -201,6 +225,19 @@ CONF = { }, } +ALL_FIELDS = [ + "track_number", + "title", + "artist", + "album_artist", + "album", + "date", + "musicbrainz_albumid", + "musicbrainz_artistid", + "musicbrainz_albumartistid", + "musicbrainz_recordingid", +] + class Metadata(object): def __init__(self, path): @@ -238,6 +275,20 @@ class Metadata(object): v = field.to_python(v) return v + def all(self): + """ + Return a dict containing all metadata of the file + """ + + data = {} + for field in ALL_FIELDS: + try: + data[field] = self.get(field, None) + except (TagNotFound, forms.ValidationError): + data[field] = None + + return data + def get_picture(self, picture_type="cover_front"): ptype = getattr(mutagen.id3.PictureType, picture_type.upper()) try: diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 51f1d4286a..55f1c77b8a 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,4 +1,5 @@ import datetime +import logging import os import tempfile import uuid @@ -21,11 +22,14 @@ from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api import musicbrainz from funkwhale_api.common import fields +from funkwhale_api.common import 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 . import importers, metadata, utils +logger = logging.getLogger(__file__) + def empty_dict(): return {} @@ -240,14 +244,35 @@ class Album(APIModelMixin): def get_image(self, data=None): if data: - f = ContentFile(data["content"]) extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} extension = extensions.get(data["mimetype"], "jpg") - self.cover.save("{}.{}".format(self.uuid, extension), f) - else: + if data.get("content"): + # we have to cover itself + f = ContentFile(data["content"]) + elif data.get("url"): + # we can fetch from a url + try: + response = session.get_session().get( + data.get("url"), + timeout=3, + verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, + ) + response.raise_for_status() + except Exception as e: + logger.warn( + "Cannot download cover at url %s: %s", data.get("url"), e + ) + return + else: + f = ContentFile(response.content) + self.cover.save("{}.{}".format(self.uuid, extension), f, save=False) + self.save(update_fields=["cover"]) + return self.cover.file + if self.mbid: image_data = musicbrainz.api.images.get_front(str(self.mbid)) f = ContentFile(image_data) - self.cover.save("{0}.jpg".format(self.mbid), f) + self.cover.save("{0}.jpg".format(self.mbid), f, save=False) + self.save(update_fields=["cover"]) return self.cover.file def __str__(self): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 61ee155856..0a4c042255 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -1,9 +1,10 @@ +import collections import logging import os from django.utils import timezone from django.db import transaction -from django.db.models import F +from django.db.models import F, Q from django.dispatch import receiver from musicbrainzngs import ResponseError @@ -14,7 +15,6 @@ from funkwhale_api.common import preferences from funkwhale_api.federation import activity, actors, routes from funkwhale_api.federation import library as lb from funkwhale_api.federation import library as federation_serializers -from funkwhale_api.providers.acoustid import get_acoustid_client from funkwhale_api.taskapp import celery from . import lyrics as lyrics_utils @@ -26,102 +26,32 @@ from . import serializers logger = logging.getLogger(__name__) -@celery.app.task(name="acoustid.set_on_upload") -@celery.require_instance(models.Upload, "upload") -def set_acoustid_on_upload(upload): - client = get_acoustid_client() - result = client.get_best_match(upload.audio_file.path) - - def update(id): - upload.acoustid_track_id = id - upload.save(update_fields=["acoustid_track_id"]) - return id - - if result: - return update(result["id"]) - - -def import_track_from_remote(metadata): - try: - track_mbid = metadata["recording"]["musicbrainz_id"] - assert track_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - return models.Track.get_or_create_from_api(mbid=track_mbid)[0] - - try: - album_mbid = metadata["release"]["musicbrainz_id"] - assert album_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) - return models.Track.get_or_create_from_title( - metadata["title"], artist=album.artist, album=album - )[0] - - try: - artist_mbid = metadata["artist"]["musicbrainz_id"] - assert artist_mbid # for null/empty values - except (KeyError, AssertionError): - pass - else: - artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) - album, _ = models.Album.get_or_create_from_title( - metadata["album_title"], artist=artist - ) - return models.Track.get_or_create_from_title( - metadata["title"], artist=artist, album=album - )[0] - - # worst case scenario, we have absolutely no way to link to a - # musicbrainz resource, we rely on the name/titles - artist, _ = models.Artist.get_or_create_from_name(metadata["artist_name"]) - album, _ = models.Album.get_or_create_from_title( - metadata["album_title"], artist=artist - ) - return models.Track.get_or_create_from_title( - metadata["title"], artist=artist, album=album - )[0] - - -def update_album_cover(album, upload, replace=False): +def update_album_cover(album, source=None, cover_data=None, replace=False): if album.cover and not replace: return - if upload: - # maybe the file has a cover embedded? + if cover_data: + return album.get_image(data=cover_data) + + if source and source.startswith("file://"): + # let's look for a cover in the same directory + path = os.path.dirname(source.replace("file://", "", 1)) + logger.info("[Album %s] scanning covers from %s", album.pk, path) + cover = get_cover_from_fs(path) + if cover: + return album.get_image(data=cover) + if album.mbid: try: - metadata = upload.get_metadata() - except FileNotFoundError: - metadata = None - if metadata: - cover = metadata.get_picture("cover_front") - if cover: - # best case scenario, cover is embedded in the track - logger.info("[Album %s] Using cover embedded in file", album.pk) - return album.get_image(data=cover) - if upload.source and upload.source.startswith("file://"): - # let's look for a cover in the same directory - path = os.path.dirname(upload.source.replace("file://", "", 1)) - logger.info("[Album %s] scanning covers from %s", album.pk, path) - cover = get_cover_from_fs(path) - if cover: - return album.get_image(data=cover) - if not album.mbid: - return - try: - logger.info( - "[Album %s] Fetching cover from musicbrainz release %s", - album.pk, - str(album.mbid), - ) - return album.get_image() - except ResponseError as exc: - logger.warning( - "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc) - ) + logger.info( + "[Album %s] Fetching cover from musicbrainz release %s", + album.pk, + str(album.mbid), + ) + return album.get_image() + except ResponseError as exc: + logger.warning( + "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc) + ) IMAGE_TYPES = [("jpg", "image/jpeg"), ("png", "image/png")] @@ -244,15 +174,15 @@ def scan_library_page(library_scan, page_url): scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page) -def getter(data, *keys): +def getter(data, *keys, default=None): if not data: - return + return default v = data for k in keys: try: v = v[k] except KeyError: - return + return default return v @@ -269,12 +199,17 @@ def fail_import(upload, error_code): upload.import_details = {"error_code": error_code} upload.import_date = timezone.now() upload.save(update_fields=["import_details", "import_status", "import_date"]) - signals.upload_import_status_updated.send( - old_status=old_status, - new_status=upload.import_status, - upload=upload, - sender=None, + + broadcast = getter( + upload.import_metadata, "funkwhale", "config", "broadcast", default=True ) + if broadcast: + signals.upload_import_status_updated.send( + old_status=old_status, + new_status=upload.import_status, + upload=upload, + sender=None, + ) @celery.app.task(name="music.process_upload") @@ -285,22 +220,29 @@ def fail_import(upload, error_code): "upload", ) def process_upload(upload): - data = upload.import_metadata or {} + import_metadata = upload.import_metadata or {} old_status = upload.import_status + audio_file = upload.get_audio_file() try: - track = get_track_from_import_metadata(upload.import_metadata or {}) - if not track and upload.audio_file: - # easy ways did not work. Now we have to be smart and use - # metadata from the file itself if any - track = import_track_data_from_file(upload.audio_file.file, hints=data) - if not track and upload.metadata: - # we can try to import using federation metadata - track = import_track_from_remote(upload.metadata) + additional_data = {} + if not audio_file: + # we can only rely on user proveded data + final_metadata = import_metadata + else: + # we use user provided data and data from the file itself + m = metadata.Metadata(audio_file) + file_metadata = m.all() + final_metadata = collections.ChainMap( + additional_data, import_metadata, file_metadata + ) + additional_data["cover_data"] = m.get_picture("cover_front") + additional_data["upload_source"] = upload.source + track = get_track_from_import_metadata(final_metadata) except UploadImportError as e: return fail_import(upload, e.code) except Exception: - fail_import(upload, "unknown_error") - raise + return fail_import(upload, "unknown_error") + # under some situations, we want to skip the import ( # for instance if the user already owns the files) owned_duplicates = get_owned_duplicates(upload, track) @@ -342,33 +284,69 @@ def process_upload(upload): "bitrate", ] ) - signals.upload_import_status_updated.send( - old_status=old_status, - new_status=upload.import_status, - upload=upload, - sender=None, + broadcast = getter( + import_metadata, "funkwhale", "config", "broadcast", default=True ) - routes.outbox.dispatch( - {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload} + if broadcast: + signals.upload_import_status_updated.send( + old_status=old_status, + new_status=upload.import_status, + upload=upload, + sender=None, + ) + dispatch_outbox = getter( + import_metadata, "funkwhale", "config", "dispatch_outbox", default=True ) - if not track.album.cover: - update_album_cover(track.album, upload) - + if dispatch_outbox: + routes.outbox.dispatch( + {"type": "Create", "object": {"type": "Audio"}}, context={"upload": upload} + ) -def get_track_from_import_metadata(data): - track_mbid = getter(data, "track", "mbid") - track_uuid = getter(data, "track", "uuid") - if track_mbid: - # easiest case: there is a MBID provided in the import_metadata - return models.Track.get_or_create_from_api(mbid=track_mbid)[0] - if track_uuid: - # another easy case, we have a reference to a uuid of a track that - # already exists in our database - try: - return models.Track.objects.get(uuid=track_uuid) - except models.Track.DoesNotExist: - raise UploadImportError(code="track_uuid_not_found") +def federation_audio_track_to_metadata(payload): + """ + Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data, + returns a correct metadata payload for use with get_track_from_import_metadata. + """ + musicbrainz_recordingid = payload.get("musicbrainzId") + musicbrainz_artistid = payload["artists"][0].get("musicbrainzId") + musicbrainz_albumartistid = payload["album"]["artists"][0].get("musicbrainzId") + musicbrainz_albumid = payload["album"].get("musicbrainzId") + + new_data = { + "title": payload["name"], + "album": payload["album"]["name"], + "track_number": payload["position"], + "artist": payload["artists"][0]["name"], + "album_artist": payload["album"]["artists"][0]["name"], + "date": payload["album"].get("released"), + # musicbrainz + "musicbrainz_recordingid": str(musicbrainz_recordingid) + if musicbrainz_recordingid + else None, + "musicbrainz_artistid": str(musicbrainz_artistid) + if musicbrainz_artistid + else None, + "musicbrainz_albumartistid": str(musicbrainz_albumartistid) + if musicbrainz_albumartistid + else None, + "musicbrainz_albumid": str(musicbrainz_albumid) + if musicbrainz_albumid + else None, + # federation + "fid": payload["id"], + "artist_fid": payload["artists"][0]["id"], + "album_artist_fid": payload["album"]["artists"][0]["id"], + "album_fid": payload["album"]["id"], + "fdate": payload["published"], + "album_fdate": payload["album"]["published"], + "album_artist_fdate": payload["album"]["artists"][0]["published"], + "artist_fdate": payload["artists"][0]["published"], + } + cover = payload["album"].get("cover") + if cover: + new_data["cover_data"] = {"mimetype": cover["mediaType"], "url": cover["href"]} + return new_data def get_owned_duplicates(upload, track): @@ -385,45 +363,191 @@ def get_owned_duplicates(upload, track): ) +def get_best_candidate_or_create(model, query, defaults, sort_fields): + """ + Like queryset.get_or_create() but does not crash if multiple objects + are returned on the get() call + """ + candidates = model.objects.filter(query) + if candidates: + + return sort_candidates(candidates, sort_fields)[0], False + + return model.objects.create(**defaults), True + + +def sort_candidates(candidates, important_fields): + """ + Given a list of objects and a list of fields, + will return a sorted list of those objects by score. + + Score is higher for objects that have a non-empty attribute + that is also present in important fields:: + + artist1 = Artist(mbid=None, fid=None) + artist2 = Artist(mbid="something", fid=None) + + # artist2 has a mbid, so is sorted first + assert sort_candidates([artist1, artist2], ['mbid'])[0] == artist2 + + Only supports string fields. + """ + + # map each fields to its score, giving a higher score to first fields + fields_scores = {f: i + 1 for i, f in enumerate(sorted(important_fields))} + candidates_with_scores = [] + for candidate in candidates: + current_score = 0 + for field, score in fields_scores.items(): + v = getattr(candidate, field, "") + if v: + current_score += score + + candidates_with_scores.append((candidate, current_score)) + + return [c for c, s in reversed(sorted(candidates_with_scores, key=lambda v: v[1]))] + + @transaction.atomic -def import_track_data_from_file(file, hints={}): - data = metadata.Metadata(file) - album = None +def get_track_from_import_metadata(data): + track_uuid = getter(data, "funkwhale", "track", "uuid") + + if track_uuid: + # easy case, we have a reference to a uuid of a track that + # already exists in our database + try: + track = models.Track.objects.get(uuid=track_uuid) + except models.Track.DoesNotExist: + raise UploadImportError(code="track_uuid_not_found") + + if not track.album.cover: + update_album_cover( + track.album, + source=data.get("upload_source"), + cover_data=data.get("cover_data"), + ) + return track + + from_activity_id = data.get("from_activity_id", None) track_mbid = data.get("musicbrainz_recordingid", None) album_mbid = data.get("musicbrainz_albumid", None) + track_fid = getter(data, "fid") + + query = None if album_mbid and track_mbid: - # to gain performance and avoid additional mb lookups, - # we import from the release data, which is already cached - return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0] - elif track_mbid: - return models.Track.get_or_create_from_api(track_mbid)[0] - elif album_mbid: - album = models.Album.get_or_create_from_api(album_mbid)[0] - - artist = album.artist if album else None + query = Q(mbid=track_mbid, album__mbid=album_mbid) + + if track_fid: + query = query | Q(fid=track_fid) if query else Q(fid=track_fid) + + if query: + # second easy case: we have a (track_mbid, album_mbid) pair or + # a federation uuid we can check on + try: + return sort_candidates(models.Track.objects.filter(query), ["mbid", "fid"])[ + 0 + ] + except IndexError: + pass + + # get / create artist and album artist artist_mbid = data.get("musicbrainz_artistid", None) - if not artist: - if artist_mbid: - artist = models.Artist.get_or_create_from_api(artist_mbid)[0] - else: - artist = models.Artist.objects.get_or_create( - name__iexact=data.get("artist"), defaults={"name": data.get("artist")} - )[0] - - release_date = data.get("date", default=None) - if not album: - album = models.Album.objects.get_or_create( - title__iexact=data.get("album"), - artist=artist, - defaults={"title": data.get("album"), "release_date": release_date}, + artist_fid = data.get("artist_fid", None) + artist_name = data["artist"] + query = Q(name__iexact=artist_name) + if artist_mbid: + query |= Q(mbid=artist_mbid) + if artist_fid: + query |= Q(fid=artist_fid) + defaults = { + "name": artist_name, + "mbid": artist_mbid, + "fid": artist_fid, + "from_activity_id": from_activity_id, + } + if data.get("artist_fdate"): + defaults["creation_date"] = data.get("artist_fdate") + + artist = get_best_candidate_or_create( + models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] + )[0] + + album_artist_name = data.get("album_artist", artist_name) + if album_artist_name == artist_name: + album_artist = artist + else: + query = Q(name__iexact=album_artist_name) + album_artist_mbid = data.get("musicbrainz_albumartistid", None) + album_artist_fid = data.get("album_artist_fid", None) + if album_artist_mbid: + query |= Q(mbid=album_artist_mbid) + if album_artist_fid: + query |= Q(fid=album_artist_fid) + defaults = { + "name": album_artist_name, + "mbid": album_artist_mbid, + "fid": album_artist_fid, + "from_activity_id": from_activity_id, + } + if data.get("album_artist_fdate"): + defaults["creation_date"] = data.get("album_artist_fdate") + + album_artist = get_best_candidate_or_create( + models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] )[0] - position = data.get("track_number", default=None) - track = models.Track.objects.get_or_create( - title__iexact=data.get("title"), - album=album, - defaults={"title": data.get("title"), "position": position}, + + # get / create album + album_title = data["album"] + album_fid = data.get("album_fid", None) + query = Q(title__iexact=album_title, artist=album_artist) + if album_mbid: + query |= Q(mbid=album_mbid) + if album_fid: + query |= Q(fid=album_fid) + defaults = { + "title": album_title, + "artist": album_artist, + "mbid": album_mbid, + "release_date": data.get("date"), + "fid": album_fid, + "from_activity_id": from_activity_id, + } + if data.get("album_fdate"): + defaults["creation_date"] = data.get("album_fdate") + + album = get_best_candidate_or_create( + models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] )[0] + if not album.cover: + update_album_cover( + album, source=data.get("upload_source"), cover_data=data.get("cover_data") + ) + + # get / create track + track_title = data["title"] + track_number = data.get("track_number", 1) + query = Q(title__iexact=track_title, artist=artist, album=album) + if track_mbid: + query |= Q(mbid=track_mbid) + if track_fid: + query |= Q(fid=track_fid) + defaults = { + "title": track_title, + "album": album, + "mbid": track_mbid, + "artist": artist, + "position": track_number, + "fid": track_fid, + "from_activity_id": from_activity_id, + } + if data.get("fdate"): + defaults["creation_date"] = data.get("fdate") + + track = get_best_candidate_or_create( + models.Track, query, defaults=defaults, sort_fields=["mbid", "fid"] + )[0] + return track @@ -432,6 +556,7 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw user = upload.library.actor.get_user() if not user: return + group = "user.{}.imports".format(user.pk) channels.group_send( group, diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index bc1c9af0ae..d4917be5e9 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -77,6 +77,29 @@ class Command(BaseCommand): "with their newest version." ), ) + parser.add_argument( + "--outbox", + action="store_true", + dest="outbox", + default=False, + help=( + "Use this flag to notify library followers of newly imported files. " + "You'll likely want to keep this disabled for CLI imports, especially if" + "you plan to import hundreds or thousands of files, as it will cause a lot " + "of overhead on your server and on servers you are federating with." + ), + ) + + parser.add_argument( + "--broadcast", + action="store_true", + dest="broadcast", + default=False, + help=( + "Use this flag to enable realtime updates about the import in the UI. " + "This causes some overhead, so it's disabled by default." + ), + ) parser.add_argument( "--reference", @@ -261,6 +284,8 @@ class Command(BaseCommand): async_, options["replace"], options["in_place"], + options["outbox"], + options["broadcast"], ) except Exception as e: if options["exit_on_failure"]: @@ -272,11 +297,29 @@ class Command(BaseCommand): errors.append((path, "{} {}".format(e.__class__.__name__, e))) return errors - def create_upload(self, path, reference, library, async_, replace, in_place): + def create_upload( + self, + path, + reference, + library, + async_, + replace, + in_place, + dispatch_outbox, + broadcast, + ): import_handler = tasks.process_upload.delay if async_ else tasks.process_upload upload = models.Upload(library=library, import_reference=reference) upload.source = "file://" + path - upload.import_metadata = {"replace": replace} + upload.import_metadata = { + "funkwhale": { + "config": { + "replace": replace, + "dispatch_outbox": dispatch_outbox, + "broadcast": broadcast, + } + } + } if not in_place: name = os.path.basename(path) with open(path, "rb") as f: diff --git a/api/requirements/local.txt b/api/requirements/local.txt index f11f976b8b..c12f1ecb82 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -10,3 +10,4 @@ django-debug-toolbar>=1.9,<1.10 # improved REPL ipdb==0.8.1 black +profiling diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 1694e5623f..a1688127c4 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -11,7 +11,7 @@ import uuid from faker.providers import internet as internet_provider import factory import pytest -import requests_mock + from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache from django.core.files import uploadedfile @@ -271,14 +271,13 @@ def media_root(settings): shutil.rmtree(tmp_dir) -@pytest.fixture -def r_mock(): +@pytest.fixture(autouse=True) +def r_mock(requests_mock): """ Returns a requests_mock.mock() object you can use to mock HTTP calls made using python-requests """ - with requests_mock.mock() as m: - yield m + yield requests_mock @pytest.fixture diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 00bb011f25..54e044c312 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,3 +1,4 @@ +import io import pytest import uuid @@ -588,42 +589,6 @@ def test_music_library_serializer_from_private(factories, mocker): ) -@pytest.mark.parametrize( - "model,serializer_class", - [ - ("music.Artist", serializers.ArtistSerializer), - ("music.Album", serializers.AlbumSerializer), - ("music.Track", serializers.TrackSerializer), - ], -) -def test_music_entity_serializer_create_existing_mbid( - model, serializer_class, factories -): - entity = factories[model]() - data = {"musicbrainzId": str(entity.mbid), "id": "https://noop"} - serializer = serializer_class() - - assert serializer.create(data) == entity - - -@pytest.mark.parametrize( - "model,serializer_class", - [ - ("music.Artist", serializers.ArtistSerializer), - ("music.Album", serializers.AlbumSerializer), - ("music.Track", serializers.TrackSerializer), - ], -) -def test_music_entity_serializer_create_existing_fid( - model, serializer_class, factories -): - entity = factories[model](fid="https://entity.url") - data = {"musicbrainzId": None, "id": "https://entity.url"} - serializer = serializer_class() - - assert serializer.create(data) == entity - - def test_activity_pub_artist_serializer_to_ap(factories): artist = factories["music.Artist"]() expected = { @@ -639,30 +604,6 @@ def test_activity_pub_artist_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_artist_serializer_from_ap(factories): - activity = factories["federation.Activity"]() - - published = timezone.now() - data = { - "type": "Artist", - "id": "http://hello.artist", - "name": "John Smith", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - } - serializer = serializers.ArtistSerializer(data=data, context={"activity": activity}) - - assert serializer.is_valid(raise_exception=True) - - artist = serializer.save() - - assert artist.from_activity == activity - assert artist.name == data["name"] - assert artist.fid == data["id"] - assert str(artist.mbid) == data["musicbrainzId"] - assert artist.creation_date == published - - def test_activity_pub_album_serializer_to_ap(factories): album = factories["music.Album"]() @@ -671,7 +612,11 @@ def test_activity_pub_album_serializer_to_ap(factories): "type": "Album", "id": album.fid, "name": album.title, - "cover": {"type": "Image", "url": utils.full_url(album.cover.url)}, + "cover": { + "type": "Link", + "mediaType": "image/jpeg", + "href": utils.full_url(album.cover.url), + }, "musicbrainzId": album.mbid, "published": album.creation_date.isoformat(), "released": album.release_date.isoformat(), @@ -686,49 +631,6 @@ def test_activity_pub_album_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_album_serializer_from_ap(factories): - activity = factories["federation.Activity"]() - - published = timezone.now() - released = timezone.now().date() - data = { - "type": "Album", - "id": "http://hello.album", - "name": "Purple album", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - "released": released.isoformat(), - "artists": [ - { - "type": "Artist", - "id": "http://hello.artist", - "name": "John Smith", - "musicbrainzId": str(uuid.uuid4()), - "published": published.isoformat(), - } - ], - } - serializer = serializers.AlbumSerializer(data=data, context={"activity": activity}) - - assert serializer.is_valid(raise_exception=True) - - album = serializer.save() - artist = album.artist - - assert album.from_activity == activity - assert album.title == data["name"] - assert album.fid == data["id"] - assert str(album.mbid) == data["musicbrainzId"] - assert album.creation_date == published - assert album.release_date == released - - assert artist.from_activity == activity - assert artist.name == data["artists"][0]["name"] - assert artist.fid == data["artists"][0]["id"] - assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] - assert artist.creation_date == published - - def test_activity_pub_track_serializer_to_ap(factories): track = factories["music.Track"]() expected = { @@ -753,7 +655,7 @@ def test_activity_pub_track_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_track_serializer_from_ap(factories): +def test_activity_pub_track_serializer_from_ap(factories, r_mock): activity = factories["federation.Activity"]() published = timezone.now() released = timezone.now().date() @@ -771,6 +673,11 @@ def test_activity_pub_track_serializer_from_ap(factories): "musicbrainzId": str(uuid.uuid4()), "published": published.isoformat(), "released": released.isoformat(), + "cover": { + "type": "Link", + "href": "https://cover.image/test.png", + "mediaType": "image/png", + }, "artists": [ { "type": "Artist", @@ -791,12 +698,14 @@ def test_activity_pub_track_serializer_from_ap(factories): } ], } + r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) serializer = serializers.TrackSerializer(data=data, context={"activity": activity}) assert serializer.is_valid(raise_exception=True) track = serializer.save() album = track.album artist = track.artist + album_artist = track.album.artist assert track.from_activity == activity assert track.fid == data["id"] @@ -806,7 +715,8 @@ def test_activity_pub_track_serializer_from_ap(factories): assert str(track.mbid) == data["musicbrainzId"] assert album.from_activity == activity - + assert album.cover.read() == b"coucou" + assert album.cover.path.endswith(".png") assert album.title == data["album"]["name"] assert album.fid == data["album"]["id"] assert str(album.mbid) == data["album"]["musicbrainzId"] @@ -819,6 +729,12 @@ def test_activity_pub_track_serializer_from_ap(factories): assert str(artist.mbid) == data["artists"][0]["musicbrainzId"] assert artist.creation_date == published + assert album_artist.from_activity == activity + assert album_artist.name == data["album"]["artists"][0]["name"] + assert album_artist.fid == data["album"]["artists"][0]["id"] + assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"] + assert album_artist.creation_date == published + def test_activity_pub_upload_serializer_from_ap(factories, mocker): activity = factories["federation.Activity"]() diff --git a/api/tests/music/test.mp3 b/api/tests/music/test.mp3 index 8502de71b8284e9f30a397f58401d96fc42dbb17..6c1f52a35f665306af4959bb5ed024d2abea544a 100644 GIT binary patch delta 24122 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 Q$uF7wus~$`g)SCH0E8@z=Kufz delta 24097 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 KOux{@;s^i&YNEyf diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index f105b6b7e3..82c991c0b2 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -9,21 +9,46 @@ from funkwhale_api.music import metadata DATA_DIR = os.path.dirname(os.path.abspath(__file__)) +def test_get_all_metadata_at_once(): + path = os.path.join(DATA_DIR, "test.ogg") + data = metadata.Metadata(path) + + expected = { + "title": "Peer Gynt Suite no. 1, op. 46: I. Morning", + "artist": "Edvard Grieg", + "album_artist": "Edvard Grieg", + "album": "Peer Gynt Suite no. 1, op. 46", + "date": datetime.date(2012, 8, 15), + "track_number": 1, + "musicbrainz_albumid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), + "musicbrainz_recordingid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"), + "musicbrainz_artistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + "musicbrainz_albumartistid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + } + + assert data.all() == expected + + @pytest.mark.parametrize( "field,value", [ ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), ("artist", "Edvard Grieg"), + ("album_artist", "Edvard Grieg"), ("album", "Peer Gynt Suite no. 1, op. 46"), ("date", datetime.date(2012, 8, 15)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75")), ("musicbrainz_recordingid", uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656")), ("musicbrainz_artistid", uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823")), + ( + "musicbrainz_albumartistid", + uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + ), ], ) -def test_can_get_metadata_from_opus_file(field, value): - path = os.path.join(DATA_DIR, "test.opus") +def test_can_get_metadata_from_ogg_file(field, value): + path = os.path.join(DATA_DIR, "test.ogg") data = metadata.Metadata(path) assert data.get(field) == value @@ -34,16 +59,21 @@ def test_can_get_metadata_from_opus_file(field, value): [ ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), ("artist", "Edvard Grieg"), + ("album_artist", "Edvard Grieg"), ("album", "Peer Gynt Suite no. 1, op. 46"), ("date", datetime.date(2012, 8, 15)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75")), ("musicbrainz_recordingid", uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656")), ("musicbrainz_artistid", uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823")), + ( + "musicbrainz_albumartistid", + uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + ), ], ) -def test_can_get_metadata_from_ogg_file(field, value): - path = os.path.join(DATA_DIR, "test.ogg") +def test_can_get_metadata_from_opus_file(field, value): + path = os.path.join(DATA_DIR, "test.opus") data = metadata.Metadata(path) assert data.get(field) == value @@ -54,12 +84,17 @@ def test_can_get_metadata_from_ogg_file(field, value): [ ("title", "Drei Kreuze (dass wir hier sind)"), ("artist", "Die Toten Hosen"), + ("album_artist", "Die Toten Hosen"), ("album", "Ballast der Republik"), ("date", datetime.date(2012, 5, 4)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf")), ("musicbrainz_recordingid", uuid.UUID("124d0150-8627-46bc-bc14-789a3bc960c8")), ("musicbrainz_artistid", uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1")), + ( + "musicbrainz_albumartistid", + uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + ), ], ) def test_can_get_metadata_from_ogg_theora_file(field, value): @@ -73,13 +108,18 @@ def test_can_get_metadata_from_ogg_theora_file(field, value): "field,value", [ ("title", "Bend"), - ("artist", "Bindrpilot"), + ("artist", "Binärpilot"), + ("album_artist", "Binärpilot"), ("album", "You Can't Stop Da Funk"), ("date", datetime.date(2006, 2, 7)), ("track_number", 2), ("musicbrainz_albumid", uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124")), ("musicbrainz_recordingid", uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb")), ("musicbrainz_artistid", uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13")), + ( + "musicbrainz_albumartistid", + uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), + ), ], ) def test_can_get_metadata_from_id3_mp3_file(field, value): @@ -108,12 +148,17 @@ def test_can_get_pictures(name): [ ("title", "999,999"), ("artist", "Nine Inch Nails"), + ("album_artist", "Nine Inch Nails"), ("album", "The Slip"), ("date", datetime.date(2008, 5, 5)), ("track_number", 1), ("musicbrainz_albumid", uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1")), ("musicbrainz_recordingid", uuid.UUID("30f3f33e-8d0c-4e69-8539-cbd701d18f28")), ("musicbrainz_artistid", uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da")), + ( + "musicbrainz_albumartistid", + uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), + ), ], ) def test_can_get_metadata_from_flac_file(field, value): @@ -133,7 +178,12 @@ def test_can_get_metadata_from_flac_file_not_crash_if_empty(): @pytest.mark.parametrize( "field_name", - ["musicbrainz_artistid", "musicbrainz_albumid", "musicbrainz_recordingid"], + [ + "musicbrainz_artistid", + "musicbrainz_albumid", + "musicbrainz_recordingid", + "musicbrainz_albumartistid", + ], ) def test_mbid_clean_keeps_only_first(field_name): u1 = str(uuid.uuid4()) diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index c58bce7db1..de5e0310f6 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1,12 +1,14 @@ import datetime +import io import os import pytest import uuid from django.core.paginator import Paginator +from django.utils import timezone from funkwhale_api.federation import serializers as federation_serializers -from funkwhale_api.music import signals, tasks +from funkwhale_api.music import metadata, signals, tasks DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -16,84 +18,163 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_can_create_track_from_file_metadata_no_mbid(db, mocker): metadata = { - "artist": ["Test artist"], - "album": ["Test album"], - "title": ["Test track"], - "TRACKNUMBER": ["4"], - "date": ["2012-08-15"], + "title": "Test track", + "artist": "Test artist", + "album": "Test album", + "date": datetime.date(2012, 8, 15), + "track_number": 4, } - mocker.patch("mutagen.File", return_value=metadata) - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" - ) - track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) - assert track.title == metadata["title"][0] + assert track.title == metadata["title"] assert track.mbid is None assert track.position == 4 - assert track.album.title == metadata["album"][0] + assert track.album.title == metadata["album"] assert track.album.mbid is None assert track.album.release_date == datetime.date(2012, 8, 15) - assert track.artist.name == metadata["artist"][0] + assert track.artist.name == metadata["artist"] assert track.artist.mbid is None def test_can_create_track_from_file_metadata_mbid(factories, mocker): - album = factories["music.Album"]() - artist = factories["music.Artist"]() - mocker.patch( - "funkwhale_api.music.models.Album.get_or_create_from_api", - return_value=(album, True), - ) + metadata = { + "title": "Test track", + "artist": "Test artist", + "album_artist": "Test album artist", + "album": "Test album", + "date": datetime.date(2012, 8, 15), + "track_number": 4, + "musicbrainz_albumid": "ce40cdb1-a562-4fd8-a269-9269f98d4124", + "musicbrainz_recordingid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "musicbrainz_artistid": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", + "musicbrainz_albumartistid": "9c6bddde-6478-4d9f-ad0d-03f6fcb19e13", + } - album_data = { - "release": { - "id": album.mbid, - "medium-list": [ - { - "track-list": [ - { - "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", - "position": "4", - "number": "4", - "recording": { - "id": "2109e376-132b-40ad-b993-2bb6812e19d4", - "title": "Teen Age Riot", - "artist-credit": [ - {"artist": {"id": artist.mbid, "name": artist.name}} - ], - }, - } - ], - "track-count": 1, - } - ], - } + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.mbid == metadata["musicbrainz_recordingid"] + assert track.position == 4 + assert track.album.title == metadata["album"] + assert track.album.mbid == metadata["musicbrainz_albumid"] + assert track.album.artist.mbid == metadata["musicbrainz_albumartistid"] + assert track.album.artist.name == metadata["album_artist"] + assert track.album.release_date == datetime.date(2012, 8, 15) + assert track.artist.name == metadata["artist"] + assert track.artist.mbid == metadata["musicbrainz_artistid"] + + +def test_can_create_track_from_file_metadata_mbid_existing_album_artist( + factories, mocker +): + artist = factories["music.Artist"]() + album = factories["music.Album"]() + metadata = { + "artist": "", + "album": "", + "title": "Hello", + "track_number": 4, + "musicbrainz_albumid": album.mbid, + "musicbrainz_recordingid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "musicbrainz_artistid": artist.mbid, + "musicbrainz_albumartistid": album.artist.mbid, } - mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data) - track_data = album_data["release"]["medium-list"][0]["track-list"][0] + + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.mbid == metadata["musicbrainz_recordingid"] + assert track.position == 4 + assert track.album == album + assert track.artist == artist + + +def test_can_create_track_from_file_metadata_fid_existing_album_artist( + factories, mocker +): + artist = factories["music.Artist"]() + album = factories["music.Album"]() metadata = { - "musicbrainz_albumid": [album.mbid], - "musicbrainz_trackid": [track_data["recording"]["id"]], + "artist": "", + "album": "", + "title": "Hello", + "track_number": 4, + "fid": "https://hello", + "album_fid": album.fid, + "artist_fid": artist.fid, + "album_artist_fid": album.artist.fid, } - mocker.patch("mutagen.File", return_value=metadata) - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" - ) - track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg")) - assert track.title == track_data["recording"]["title"] - assert track.mbid == track_data["recording"]["id"] + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.fid == metadata["fid"] assert track.position == 4 assert track.album == album assert track.artist == artist -def test_upload_import_mbid(now, factories, temp_signal, mocker): +def test_can_create_track_from_file_metadata_federation(factories, mocker, r_mock): + metadata = { + "artist": "Artist", + "album": "Album", + "album_artist": "Album artist", + "title": "Hello", + "track_number": 4, + "fid": "https://hello", + "album_fid": "https://album.fid", + "artist_fid": "https://artist.fid", + "album_artist_fid": "https://album.artist.fid", + "fdate": timezone.now(), + "album_fdate": timezone.now(), + "album_artist_fdate": timezone.now(), + "artist_fdate": timezone.now(), + "cover_data": {"url": "https://cover/hello.png", "mimetype": "image/png"}, + } + r_mock.get(metadata["cover_data"]["url"], body=io.BytesIO(b"coucou")) + mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) + + track = tasks.get_track_from_import_metadata(metadata) + + assert track.title == metadata["title"] + assert track.fid == metadata["fid"] + assert track.creation_date == metadata["fdate"] + assert track.position == 4 + assert track.album.cover.read() == b"coucou" + assert track.album.cover.path.endswith(".png") + assert track.album.fid == metadata["album_fid"] + assert track.album.title == metadata["album"] + assert track.album.creation_date == metadata["album_fdate"] + assert track.album.artist.fid == metadata["album_artist_fid"] + assert track.album.artist.name == metadata["album_artist"] + assert track.album.artist.creation_date == metadata["album_artist_fdate"] + assert track.artist.fid == metadata["artist_fid"] + assert track.artist.name == metadata["artist"] + assert track.artist.creation_date == metadata["artist_fdate"] + + +def test_sort_candidates(factories): + artist1 = factories["music.Artist"].build(fid=None, mbid=None) + artist2 = factories["music.Artist"].build(fid=None) + artist3 = factories["music.Artist"].build(mbid=None) + result = tasks.sort_candidates([artist1, artist2, artist3], ["mbid", "fid"]) + + assert result == [artist2, artist3, artist1] + + +def test_upload_import(now, factories, temp_signal, mocker): outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"mbid": track.mbid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}} ) with temp_signal(signals.upload_import_status_updated) as handler: @@ -123,7 +204,29 @@ def test_upload_import_get_audio_data(factories, mocker): ) track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"mbid": track.mbid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} + ) + + tasks.process_upload(upload_id=upload.pk) + + upload.refresh_from_db() + assert upload.size == 23 + assert upload.duration == 42 + assert upload.bitrate == 66 + + +def test_upload_import_in_place(factories, mocker): + mocker.patch( + "funkwhale_api.music.models.Upload.get_audio_data", + return_value={"size": 23, "duration": 42, "bitrate": 66}, + ) + track = factories["music.Track"]() + path = os.path.join(DATA_DIR, "test.ogg") + upload = factories["music.Upload"]( + track=None, + audio_file=None, + source="file://{}".format(path), + import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}, ) tasks.process_upload(upload_id=upload.pk) @@ -141,13 +244,13 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal track=track, import_status="finished", library=library, - import_metadata={"track": {"mbid": track.mbid}}, + import_metadata={"funkwhale": {"track": {"uuid": track.mbid}}}, ) duplicate = factories["music.Upload"]( track=track, import_status="pending", library=library, - import_metadata={"track": {"mbid": track.mbid}}, + import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}}, ) with temp_signal(signals.upload_import_status_updated) as handler: tasks.process_upload(upload_id=duplicate.pk) @@ -172,7 +275,7 @@ def test_upload_import_skip_existing_track_in_own_library(factories, temp_signal def test_upload_import_track_uuid(now, factories): track = factories["music.Track"]() upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"uuid": track.uuid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} ) tasks.process_upload(upload_id=upload.pk) @@ -184,9 +287,43 @@ def test_upload_import_track_uuid(now, factories): assert upload.import_date == now +def test_upload_import_skip_federation(now, factories, mocker): + outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch") + track = factories["music.Track"]() + upload = factories["music.Upload"]( + track=None, + import_metadata={ + "funkwhale": { + "track": {"uuid": track.uuid}, + "config": {"dispatch_outbox": False}, + } + }, + ) + + tasks.process_upload(upload_id=upload.pk) + + outbox.assert_not_called() + + +def test_upload_import_skip_broadcast(now, factories, mocker): + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + track = factories["music.Track"]() + upload = factories["music.Upload"]( + library__actor__local=True, + track=None, + import_metadata={ + "funkwhale": {"track": {"uuid": track.uuid}, "config": {"broadcast": False}} + }, + ) + + tasks.process_upload(upload_id=upload.pk) + + group_send.assert_not_called() + + def test_upload_import_error(factories, now, temp_signal): upload = factories["music.Upload"]( - import_metadata={"track": {"uuid": uuid.uuid4()}} + import_metadata={"funkwhale": {"track": {"uuid": uuid.uuid4()}}} ) with temp_signal(signals.upload_import_status_updated) as handler: tasks.process_upload(upload_id=upload.pk) @@ -209,32 +346,26 @@ def test_upload_import_updates_cover_if_no_cover(factories, mocker, now): album = factories["music.Album"](cover="") track = factories["music.Track"](album=album) upload = factories["music.Upload"]( - track=None, import_metadata={"track": {"uuid": track.uuid}} + track=None, import_metadata={"funkwhale": {"track": {"uuid": track.uuid}}} ) tasks.process_upload(upload_id=upload.pk) - mocked_update.assert_called_once_with(album, upload) + mocked_update.assert_called_once_with(album, source=None, cover_data=None) def test_update_album_cover_mbid(factories, mocker): album = factories["music.Album"](cover="") mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") - tasks.update_album_cover(album=album, upload=None) + tasks.update_album_cover(album=album) mocked_get.assert_called_once_with() def test_update_album_cover_file_data(factories, mocker): album = factories["music.Album"](cover="", mbid=None) - upload = factories["music.Upload"](track__album=album) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") - mocker.patch( - "funkwhale_api.music.metadata.Metadata.get_picture", - return_value={"hello": "world"}, - ) - tasks.update_album_cover(album=album, upload=upload) - upload.get_metadata() + tasks.update_album_cover(album=album, cover_data={"hello": "world"}) mocked_get.assert_called_once_with(data={"hello": "world"}) @@ -245,19 +376,87 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m with open(image_path, "rb") as f: image_content = f.read() album = factories["music.Album"](cover="", mbid=None) - upload = factories["music.Upload"]( - track__album=album, source="file://" + image_path - ) mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None) - tasks.update_album_cover(album=album, upload=upload) - upload.get_metadata() + tasks.update_album_cover(album=album, source="file://" + image_path) mocked_get.assert_called_once_with( data={"mimetype": mimetype, "content": image_content} ) +def test_federation_audio_track_to_metadata(now): + published = now + released = now.date() + payload = { + "type": "Track", + "id": "http://hello.track", + "musicbrainzId": str(uuid.uuid4()), + "name": "Black in back", + "position": 5, + "published": published.isoformat(), + "album": { + "published": published.isoformat(), + "type": "Album", + "id": "http://hello.album", + "name": "Purple album", + "musicbrainzId": str(uuid.uuid4()), + "released": released.isoformat(), + "artists": [ + { + "type": "Artist", + "published": published.isoformat(), + "id": "http://hello.artist", + "name": "John Smith", + "musicbrainzId": str(uuid.uuid4()), + } + ], + }, + "artists": [ + { + "published": published.isoformat(), + "type": "Artist", + "id": "http://hello.trackartist", + "name": "Bob Smith", + "musicbrainzId": str(uuid.uuid4()), + } + ], + } + serializer = federation_serializers.TrackSerializer(data=payload) + serializer.is_valid(raise_exception=True) + expected = { + "artist": payload["artists"][0]["name"], + "album": payload["album"]["name"], + "album_artist": payload["album"]["artists"][0]["name"], + "title": payload["name"], + "date": released, + "track_number": payload["position"], + # musicbrainz + "musicbrainz_albumid": payload["album"]["musicbrainzId"], + "musicbrainz_recordingid": payload["musicbrainzId"], + "musicbrainz_artistid": payload["artists"][0]["musicbrainzId"], + "musicbrainz_albumartistid": payload["album"]["artists"][0]["musicbrainzId"], + # federation + "fid": payload["id"], + "album_fid": payload["album"]["id"], + "artist_fid": payload["artists"][0]["id"], + "album_artist_fid": payload["album"]["artists"][0]["id"], + "fdate": serializer.validated_data["published"], + "artist_fdate": serializer.validated_data["artists"][0]["published"], + "album_artist_fdate": serializer.validated_data["album"]["artists"][0][ + "published" + ], + "album_fdate": serializer.validated_data["album"]["published"], + } + + result = tasks.federation_audio_track_to_metadata(serializer.validated_data) + assert result == expected + + # ensure we never forget to test a mandatory field + for k in metadata.ALL_FIELDS: + assert k in result + + def test_scan_library_fetches_page_and_calls_scan_page(now, mocker, factories, r_mock): scan = factories["music.LibraryScan"]() collection_conf = { diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index a7b2380ed6..ad4b4be0e7 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -54,6 +54,39 @@ def test_import_files_stores_proper_data(factories, mocker, now, path): assert upload.import_reference == "cli-{}".format(now.isoformat()) assert upload.import_status == "pending" assert upload.source == "file://{}".format(path) + assert upload.import_metadata == { + "funkwhale": { + "config": {"replace": False, "dispatch_outbox": False, "broadcast": False} + } + } + + mocked_process.assert_called_once_with(upload_id=upload.pk) + + +def test_import_with_outbox_flag(factories, mocker): + library = factories["music.Library"](actor__local=True) + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", str(library.uuid), path, outbox=True, interactive=False + ) + upload = library.uploads.last() + + assert upload.import_metadata["funkwhale"]["config"]["dispatch_outbox"] is True + + mocked_process.assert_called_once_with(upload_id=upload.pk) + + +def test_import_with_broadcast_flag(factories, mocker): + library = factories["music.Library"](actor__local=True) + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_process = mocker.patch("funkwhale_api.music.tasks.process_upload") + call_command( + "import_files", str(library.uuid), path, broadcast=True, interactive=False + ) + upload = library.uploads.last() + + assert upload.import_metadata["funkwhale"]["config"]["broadcast"] is True mocked_process.assert_called_once_with(upload_id=upload.pk) @@ -67,7 +100,7 @@ def test_import_with_replace_flag(factories, mocker): ) upload = library.uploads.last() - assert upload.import_metadata["replace"] is True + assert upload.import_metadata["funkwhale"]["config"]["replace"] is True mocked_process.assert_called_once_with(upload_id=upload.pk) diff --git a/dev.yml b/dev.yml index a67085e44b..5ac74424cc 100644 --- a/dev.yml +++ b/dev.yml @@ -30,6 +30,7 @@ services: - .env.dev - .env image: postgres + command: postgres -c log_min_duration_statement=0 volumes: - "./data/${COMPOSE_PROJECT_NAME-node1}/postgres:/var/lib/postgresql/data" networks: diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index c657cc7f9b..ef34b39832 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -282,6 +282,7 @@ export default { 'search.tokens': { handler (newValue) { this.search.query = compileTokens(newValue) + this.page = 1 this.fetchData() }, deep: true @@ -290,6 +291,9 @@ export default { this.page = 1 this.fetchData() }, + page: function () { + this.fetchData() + }, ordering: function () { this.page = 1 this.fetchData() -- GitLab