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 99ed708f..71cd7a83 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 4c754ae0..21daf274 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 51f1d428..55f1c77b 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 61ee1558..0a4c0422 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 bc1c9af0..d4917be5 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 f11f976b..c12f1ecb 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 1694e562..a1688127 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 00bb011f..54e044c3 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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 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 f105b6b7..82c991c0 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 c58bce7d..de5e0310 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 a7b2380e..ad4b4be0 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 a67085e4..5ac74424 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 c657cc7f..ef34b398 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