From 1bee3a4675a63156eee4f56e8a63be4acd308d53 Mon Sep 17 00:00:00 2001 From: Eliot Berriot 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 | 455 +++++++++++------- .../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 | 86 +++- api/tests/music/test_tasks.py | 355 +++++++++++--- api/tests/test_import_audio_file.py | 35 +- dev.yml | 1 + .../views/content/libraries/FilesTable.vue | 4 + 14 files changed, 872 insertions(+), 429 deletions(-) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 99ed708f1..71cd7a831 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 4c754ae05..21daf2747 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 51f1d4286..55f1c77b8 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 61ee15585..0a4c04225 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") +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") - 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") + 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] + query = Q(mbid=track_mbid, album__mbid=album_mbid) - artist = album.artist if album else None + 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] + 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") - 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}, - )[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}, + 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] + + # 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 bc1c9af0a..d4917be5e 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 f11f976b8..c12f1ecb8 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 1694e5623..a1688127c 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 00bb011f2..54e044c31 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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

~poo%fQ+LcS0limzFpjS>5DOWJcJbPlG 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 delta 24097 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 F{{RANqQ(FK diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index f105b6b7e..82c991c0b 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -9,24 +9,24 @@ from funkwhale_api.music import metadata DATA_DIR = os.path.dirname(os.path.abspath(__file__)) -@pytest.mark.parametrize( - "field,value", - [ - ("title", "Peer Gynt Suite no. 1, op. 46: I. Morning"), - ("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")), - ], -) -def test_can_get_metadata_from_opus_file(field, value): - path = os.path.join(DATA_DIR, "test.opus") +def test_get_all_metadata_at_once(): + path = os.path.join(DATA_DIR, "test.ogg") data = metadata.Metadata(path) - assert data.get(field) == value + 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( @@ -34,12 +34,17 @@ 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): @@ -49,17 +54,47 @@ def test_can_get_metadata_from_ogg_file(field, value): assert data.get(field) == value +@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") + data = metadata.Metadata(path) + + assert data.get(field) == value + + @pytest.mark.parametrize( "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 c58bce7db..de5e0310f 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) - assert track.title == metadata["title"][0] + track = tasks.get_track_from_import_metadata(metadata) + + 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), - ) - - 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.musicbrainz.api.releases.get", return_value=album_data) - track_data = album_data["release"]["medium-list"][0]["track-list"][0] metadata = { - "musicbrainz_albumid": [album.mbid], - "musicbrainz_trackid": [track_data["recording"]["id"]], + "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", } - 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.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.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_upload_import_mbid(now, factories, temp_signal, mocker): +def test_can_create_track_from_file_metadata_fid_existing_album_artist( + factories, mocker +): + artist = factories["music.Artist"]() + album = factories["music.Album"]() + metadata = { + "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("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_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 a7b2380ed..ad4b4be0e 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 a67085e44..5ac74424c 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 c657cc7f9..ef34b3983 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()