From 05f0129025bae36fc1b1101b03687ecd698e9500 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 4 Apr 2019 16:07:43 +0200 Subject: [PATCH] Better error handling on display for import errors (#718, #583, #501, #252, #544) --- api/funkwhale_api/music/metadata.py | 329 ++++++++------ api/funkwhale_api/music/tasks.py | 145 +++--- api/tests/music/test_metadata.py | 416 +++++++++++++----- api/tests/music/test_tasks.py | 233 ++++++---- changes/changelog.d/252.feature | 1 + changes/changelog.d/718.bugfix | 1 + changes/notes.rst | 9 + docs/users/upload.rst | 27 ++ .../views/content/libraries/FilesTable.vue | 150 ++++++- 9 files changed, 915 insertions(+), 396 deletions(-) create mode 100644 changes/changelog.d/252.feature create mode 100644 changes/changelog.d/718.bugfix diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 7a105e432c..3347dfb632 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -8,7 +8,8 @@ import mutagen.oggtheora import mutagen.oggvorbis import mutagen.flac -from django import forms +from rest_framework import serializers +from rest_framework.compat import Mapping logger = logging.getLogger(__name__) NODEFAULT = object() @@ -122,85 +123,23 @@ def get_mp3_recording_id(f, k): raise TagNotFound(k) -def convert_position(v): - try: - return int(v) - except ValueError: - # maybe the position is of the form "1/4" - pass - - try: - return int(v.split("/")[0]) - except (ValueError, AttributeError, IndexError): - pass - - -class FirstUUIDField(forms.UUIDField): - def to_python(self, value): - try: - # 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 - - return super().to_python(value) - - -def get_date(value): - ADDITIONAL_FORMATS = ["%Y-%d-%m %H:%M"] # deezer date format - try: - parsed = pendulum.parse(str(value)) - return datetime.date(parsed.year, parsed.month, parsed.day) - except pendulum.exceptions.ParserError: - pass - - for date_format in ADDITIONAL_FORMATS: - try: - parsed = datetime.datetime.strptime(value, date_format) - except ValueError: - continue - else: - return datetime.date(parsed.year, parsed.month, parsed.day) - - raise ParseError("{} cannot be parsed as a date".format(value)) - - -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(), -} +VALIDATION = {} CONF = { "OggOpus": { "getter": lambda f, k: f[k][0], "fields": { - "track_number": { - "field": "TRACKNUMBER", - "to_application": convert_position, - }, - "disc_number": {"field": "DISCNUMBER", "to_application": convert_position}, + "position": {"field": "TRACKNUMBER"}, + "disc_number": {"field": "DISCNUMBER"}, "title": {}, "artist": {}, - "album_artist": { - "field": "albumartist", - "to_application": split_and_return_first(";"), - }, + "album_artist": {"field": "albumartist"}, "album": {}, - "date": {"field": "date", "to_application": get_date}, + "date": {"field": "date"}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, "musicbrainz_albumartistid": {}, - "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, + "mbid": {"field": "musicbrainz_trackid"}, "license": {}, "copyright": {}, }, @@ -208,23 +147,17 @@ CONF = { "OggVorbis": { "getter": lambda f, k: f[k][0], "fields": { - "track_number": { - "field": "TRACKNUMBER", - "to_application": convert_position, - }, - "disc_number": {"field": "DISCNUMBER", "to_application": convert_position}, + "position": {"field": "TRACKNUMBER"}, + "disc_number": {"field": "DISCNUMBER"}, "title": {}, "artist": {}, - "album_artist": { - "field": "albumartist", - "to_application": split_and_return_first(";"), - }, + "album_artist": {"field": "albumartist"}, "album": {}, - "date": {"field": "date", "to_application": get_date}, + "date": {"field": "date"}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, "musicbrainz_albumartistid": {}, - "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, + "mbid": {"field": "musicbrainz_trackid"}, "license": {}, "copyright": {}, "pictures": { @@ -236,20 +169,17 @@ CONF = { "OggTheora": { "getter": lambda f, k: f[k][0], "fields": { - "track_number": { - "field": "TRACKNUMBER", - "to_application": convert_position, - }, - "disc_number": {"field": "DISCNUMBER", "to_application": convert_position}, + "position": {"field": "TRACKNUMBER"}, + "disc_number": {"field": "DISCNUMBER"}, "title": {}, "artist": {}, "album_artist": {"field": "albumartist"}, "album": {}, - "date": {"field": "date", "to_application": get_date}, + "date": {"field": "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"}, + "mbid": {"field": "MusicBrainz Track Id"}, "license": {}, "copyright": {}, }, @@ -258,20 +188,17 @@ CONF = { "getter": get_id3_tag, "clean_pictures": clean_id3_pictures, "fields": { - "track_number": {"field": "TRCK", "to_application": convert_position}, - "disc_number": {"field": "TPOS", "to_application": convert_position}, + "position": {"field": "TRCK"}, + "disc_number": {"field": "TPOS"}, "title": {"field": "TIT2"}, "artist": {"field": "TPE1"}, "album_artist": {"field": "TPE2"}, "album": {"field": "TALB"}, - "date": {"field": "TDRC", "to_application": get_date}, + "date": {"field": "TDRC"}, "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, - }, + "mbid": {"field": "UFID", "getter": get_mp3_recording_id}, "pictures": {}, "license": {"field": "WCOP"}, "copyright": {"field": "TCOP"}, @@ -281,20 +208,17 @@ CONF = { "getter": get_flac_tag, "clean_pictures": clean_flac_pictures, "fields": { - "track_number": { - "field": "tracknumber", - "to_application": convert_position, - }, - "disc_number": {"field": "discnumber", "to_application": convert_position}, + "position": {"field": "tracknumber"}, + "disc_number": {"field": "discnumber"}, "title": {}, "artist": {}, "album_artist": {"field": "albumartist"}, "album": {}, - "date": {"field": "date", "to_application": get_date}, + "date": {"field": "date"}, "musicbrainz_albumid": {}, "musicbrainz_artistid": {}, "musicbrainz_albumartistid": {}, - "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, + "mbid": {"field": "musicbrainz_trackid"}, "test": {}, "pictures": {}, "license": {}, @@ -304,7 +228,7 @@ CONF = { } ALL_FIELDS = [ - "track_number", + "position", "disc_number", "title", "artist", @@ -314,13 +238,13 @@ ALL_FIELDS = [ "musicbrainz_albumid", "musicbrainz_artistid", "musicbrainz_albumartistid", - "musicbrainz_recordingid", + "mbid", "license", "copyright", ] -class Metadata(object): +class Metadata(Mapping): def __init__(self, filething, kind=mutagen.File): self._file = kind(filething) if self._file is None: @@ -368,6 +292,21 @@ class Metadata(object): else: return self.fallback.get(key, default=default) + def all(self): + """ + Return a dict with all support metadata fields, if they are available + """ + final = {} + for field in self._conf["fields"]: + if field in ["pictures"]: + continue + value = self.get(field, None) + if value is None: + continue + final[field] = str(value) + + return final + def _get_from_self(self, key, default=NODEFAULT): try: field_conf = self._conf["fields"][key] @@ -390,25 +329,6 @@ class Metadata(object): v = field.to_python(v) return v - def all(self, ignore_parse_errors=True): - """ - 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 - except ParseError as e: - if not ignore_parse_errors: - raise - logger.warning("Unparsable field {}: {}".format(field, str(e))) - data[field] = None - - return data - def get_picture(self, *picture_types): if not picture_types: raise ValueError("You need to request at least one picture type") @@ -430,3 +350,166 @@ class Metadata(object): for p in pictures: if p["type"] == ptype: return p + + def __getitem__(self, key): + return self.get(key) + + def __len__(self): + return 1 + + def __iter__(self): + for field in self._conf["fields"]: + yield field + + +class ArtistField(serializers.Field): + def __init__(self, *args, **kwargs): + self.for_album = kwargs.pop("for_album", False) + super().__init__(*args, **kwargs) + + def get_value(self, data): + if self.for_album: + keys = [("names", "album_artist"), ("mbids", "musicbrainz_albumartistid")] + else: + keys = [("names", "artist"), ("mbids", "musicbrainz_artistid")] + + final = {} + for field, key in keys: + final[field] = data.get(key, None) + + return final + + def to_internal_value(self, data): + # we have multiple values that can be separated by various separators + separators = [";", "/"] + # we get a list like that if tagged via musicbrainz + # ae29aae4-abfb-4609-8f54-417b1f4d64cc; 3237b5a8-ae44-400c-aa6d-cea51f0b9074; + raw_mbids = data["mbids"] + used_separator = None + mbids = [raw_mbids] + if raw_mbids: + for separator in separators: + if separator in raw_mbids: + used_separator = separator + mbids = [m.strip() for m in raw_mbids.split(separator)] + break + + # now, we split on artist names, using the same separator as the one used + # by mbids, if any + if used_separator and mbids: + names = [n.strip() for n in data["names"].split(used_separator)] + else: + names = [data["names"]] + + final = [] + for i, name in enumerate(names): + try: + mbid = mbids[i] + except IndexError: + mbid = None + artist = {"name": name, "mbid": mbid} + final.append(artist) + + field = serializers.ListField(child=ArtistSerializer(), min_length=1) + + return field.to_internal_value(final) + + +class AlbumField(serializers.Field): + def get_value(self, data): + return data + + def to_internal_value(self, data): + try: + title = data.get("album") + except TagNotFound: + raise serializers.ValidationError("Missing album tag") + final = { + "title": title, + "release_date": data.get("date", None), + "mbid": data.get("musicbrainz_albumid", None), + } + artists_field = ArtistField(for_album=True) + payload = artists_field.get_value(data) + artists = artists_field.to_internal_value(payload) + album_serializer = AlbumSerializer(data=final) + album_serializer.is_valid(raise_exception=True) + album_serializer.validated_data["artists"] = artists + return album_serializer.validated_data + + +class CoverDataField(serializers.Field): + def get_value(self, data): + return data + + def to_internal_value(self, data): + return data.get_picture("cover_front", "other") + + +class PermissiveDateField(serializers.CharField): + def to_internal_value(self, value): + if not value: + return None + value = super().to_internal_value(str(value)) + ADDITIONAL_FORMATS = [ + "%Y-%d-%m %H:%M", # deezer date format + "%Y-%W", # weird date format based on week number, see #718 + ] + + for date_format in ADDITIONAL_FORMATS: + try: + parsed = datetime.datetime.strptime(value, date_format) + except ValueError: + continue + else: + return datetime.date(parsed.year, parsed.month, parsed.day) + + try: + parsed = pendulum.parse(str(value)) + return datetime.date(parsed.year, parsed.month, parsed.day) + except pendulum.exceptions.ParserError: + pass + + return None + + +class ArtistSerializer(serializers.Serializer): + name = serializers.CharField() + mbid = serializers.UUIDField(required=False, allow_null=True) + + +class AlbumSerializer(serializers.Serializer): + title = serializers.CharField() + mbid = serializers.UUIDField(required=False, allow_null=True) + release_date = PermissiveDateField(required=False, allow_null=True) + + +class PositionField(serializers.CharField): + def to_internal_value(self, v): + v = super().to_internal_value(v) + if not v: + return v + + try: + return int(v) + except ValueError: + # maybe the position is of the form "1/4" + pass + + try: + return int(v.split("/")[0]) + except (ValueError, AttributeError, IndexError): + pass + + +class TrackMetadataSerializer(serializers.Serializer): + title = serializers.CharField() + position = PositionField(allow_null=True, required=False) + disc_number = PositionField(allow_null=True, required=False) + copyright = serializers.CharField(allow_null=True, required=False) + license = serializers.CharField(allow_null=True, required=False) + mbid = serializers.UUIDField(allow_null=True, required=False) + + album = AlbumField() + artists = ArtistField() + cover_data = CoverDataField() diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 47cb4eb38f..f64b7eed26 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -151,10 +151,11 @@ class UploadImportError(ValueError): super().__init__(code) -def fail_import(upload, error_code): +def fail_import(upload, error_code, detail=None, **fields): old_status = upload.import_status upload.import_status = "errored" - upload.import_details = {"error_code": error_code} + upload.import_details = {"error_code": error_code, "detail": detail} + upload.import_details.update(fields) upload.import_date = timezone.now() upload.save(update_fields=["import_details", "import_status", "import_date"]) @@ -182,19 +183,29 @@ def process_upload(upload): old_status = upload.import_status audio_file = upload.get_audio_file() additional_data = {} + + m = metadata.Metadata(audio_file) + try: + serializer = metadata.TrackMetadataSerializer(data=m) + serializer.is_valid() + except Exception: + fail_import(upload, "unknown_error") + raise + if not serializer.is_valid(): + detail = serializer.errors + try: + metadata_dump = m.all() + except Exception as e: + logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e)) + return fail_import( + upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump + ) + + final_metadata = collections.ChainMap( + additional_data, serializer.validated_data, import_metadata + ) + additional_data["upload_source"] = upload.source try: - 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", "other") - additional_data["upload_source"] = upload.source track = get_track_from_import_metadata(final_metadata) except UploadImportError as e: return fail_import(upload, e.code) @@ -276,43 +287,45 @@ 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.get("position") or 1, + "position": payload.get("position") or 1, "disc_number": payload.get("disc"), - "artist": payload["artists"][0]["name"], - "album_artist": payload["album"]["artists"][0]["name"], - "date": payload["album"].get("released"), "license": payload.get("license"), "copyright": payload.get("copyright"), - # 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 + "mbid": str(payload.get("musicbrainzId")) + if payload.get("musicbrainzId") else None, + "album": { + "title": payload["album"]["name"], + "fdate": payload["album"]["published"], + "fid": payload["album"]["id"], + "mbid": str(payload["album"]["musicbrainzId"]) + if payload["album"].get("musicbrainzId") + else None, + "release_date": payload["album"].get("released"), + "artists": [ + { + "fid": a["id"], + "name": a["name"], + "fdate": a["published"], + "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, + } + for a in payload["album"]["artists"] + ], + }, + "artists": [ + { + "fid": a["id"], + "name": a["name"], + "fdate": a["published"], + "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, + } + for a in payload["artists"] + ], # 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: @@ -405,8 +418,8 @@ def _get_track(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_mbid = data.get("mbid", None) + album_mbid = getter(data, "album", "mbid") track_fid = getter(data, "fid") query = None @@ -428,9 +441,12 @@ def _get_track(data): pass # get / create artist and album artist - artist_mbid = data.get("musicbrainz_artistid", None) - artist_fid = data.get("artist_fid", None) - artist_name = data["artist"] + artists = getter(data, "artists", default=[]) + artist = artists[0] + artist_mbid = artist.get("mbid", None) + artist_fid = artist.get("fid", None) + artist_name = artist["name"] + if artist_mbid: query = Q(mbid=artist_mbid) else: @@ -443,20 +459,22 @@ def _get_track(data): "fid": artist_fid, "from_activity_id": from_activity_id, } - if data.get("artist_fdate"): - defaults["creation_date"] = data.get("artist_fdate") + if artist.get("fdate"): + defaults["creation_date"] = artist.get("fdate") artist = get_best_candidate_or_create( models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"] )[0] - album_artist_name = data.get("album_artist") or artist_name + album_artists = getter(data, "album", "artists", default=artists) + album_artist = album_artists[0] + album_artist_name = album_artist.get("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) + album_artist_mbid = album_artist.get("mbid", None) + album_artist_fid = album_artist.get("fid", None) if album_artist_mbid: query |= Q(mbid=album_artist_mbid) if album_artist_fid: @@ -467,16 +485,17 @@ def _get_track(data): "fid": album_artist_fid, "from_activity_id": from_activity_id, } - if data.get("album_artist_fdate"): - defaults["creation_date"] = data.get("album_artist_fdate") + if album_artist.get("fdate"): + defaults["creation_date"] = album_artist.get("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) + album = data["album"] + album_title = album["title"] + album_fid = album.get("fid", None) if album_mbid: query = Q(mbid=album_mbid) @@ -489,12 +508,12 @@ def _get_track(data): "title": album_title, "artist": album_artist, "mbid": album_mbid, - "release_date": data.get("date"), + "release_date": album.get("release_date"), "fid": album_fid, "from_activity_id": from_activity_id, } - if data.get("album_fdate"): - defaults["creation_date"] = data.get("album_fdate") + if album.get("fdate"): + defaults["creation_date"] = album.get("fdate") album = get_best_candidate_or_create( models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"] @@ -502,10 +521,8 @@ def _get_track(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, position=track_number - ) + position = data.get("position", 1) + query = Q(title__iexact=track_title, artist=artist, album=album, position=position) if track_mbid: query |= Q(mbid=track_mbid) if track_fid: @@ -515,7 +532,7 @@ def _get_track(data): "album": album, "mbid": track_mbid, "artist": artist, - "position": track_number, + "position": position, "disc_number": data.get("disc_number"), "fid": track_fid, "from_activity_id": from_activity_id, diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index f91f7545b8..e2c180a639 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -11,45 +11,22 @@ 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, - "disc_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"), - "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", - "copyright": "Someone", - } - - 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_artist", "Edvard Grieg; Musopen Symphony Orchestra"), ("album", "Peer Gynt Suite no. 1, op. 46"), - ("date", datetime.date(2012, 8, 15)), - ("track_number", 1), - ("disc_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")), + ("date", "2012-08-15"), + ("position", "1"), + ("disc_number", "1"), + ("musicbrainz_albumid", "a766da8b-8336-47aa-a3ee-371cc41ccc75"), + ("mbid", "bd21ac48-46d8-4e78-925f-d9cc2a294656"), + ("musicbrainz_artistid", "013c8e5b-d72a-4cd3-8dee-6c64d6125823"), ( "musicbrainz_albumartistid", - uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + "013c8e5b-d72a-4cd3-8dee-6c64d6125823;5b4d7d2d-36df-4b38-95e3-a964234f520f", ), ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), ("copyright", "Someone"), @@ -62,22 +39,44 @@ def test_can_get_metadata_from_ogg_file(field, value): assert data.get(field) == value +def test_can_get_metadata_all(): + 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; Musopen Symphony Orchestra", + "album": "Peer Gynt Suite no. 1, op. 46", + "date": "2012-08-15", + "position": "1", + "disc_number": "1", + "musicbrainz_albumid": "a766da8b-8336-47aa-a3ee-371cc41ccc75", + "mbid": "bd21ac48-46d8-4e78-925f-d9cc2a294656", + "musicbrainz_artistid": "013c8e5b-d72a-4cd3-8dee-6c64d6125823", + "musicbrainz_albumartistid": "013c8e5b-d72a-4cd3-8dee-6c64d6125823;5b4d7d2d-36df-4b38-95e3-a964234f520f", + "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", + "copyright": "Someone", + } + 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_artist", "Edvard Grieg; Musopen Symphony Orchestra"), ("album", "Peer Gynt Suite no. 1, op. 46"), - ("date", datetime.date(2012, 8, 15)), - ("track_number", 1), - ("disc_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")), + ("date", "2012-08-15"), + ("position", "1"), + ("disc_number", "1"), + ("musicbrainz_albumid", "a766da8b-8336-47aa-a3ee-371cc41ccc75"), + ("mbid", "bd21ac48-46d8-4e78-925f-d9cc2a294656"), + ("musicbrainz_artistid", "013c8e5b-d72a-4cd3-8dee-6c64d6125823"), ( "musicbrainz_albumartistid", - uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + "013c8e5b-d72a-4cd3-8dee-6c64d6125823;5b4d7d2d-36df-4b38-95e3-a964234f520f", ), ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), ("copyright", "Someone"), @@ -97,16 +96,13 @@ def test_can_get_metadata_from_opus_file(field, value): ("artist", "Die Toten Hosen"), ("album_artist", "Die Toten Hosen"), ("album", "Ballast der Republik"), - ("date", datetime.date(2012, 5, 4)), - ("track_number", 1), - ("disc_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"), - ), + ("date", "2012-05-04"), + ("position", "1/16"), + ("disc_number", "1/2"), + ("musicbrainz_albumid", "1f0441ad-e609-446d-b355-809c445773cf"), + ("mbid", "124d0150-8627-46bc-bc14-789a3bc960c8"), + ("musicbrainz_artistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + ("musicbrainz_albumartistid", "c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), # somehow, I cannot successfully create an ogg theora file # with the proper license field # ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), @@ -126,16 +122,13 @@ def test_can_get_metadata_from_ogg_theora_file(field, value): ("artist", "Binärpilot"), ("album_artist", "Binärpilot"), ("album", "You Can't Stop Da Funk"), - ("date", datetime.date(2006, 2, 7)), - ("track_number", 2), - ("disc_number", 1), - ("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"), - ), + ("date", "2006-02-07"), + ("position", "2/4"), + ("disc_number", "1/1"), + ("musicbrainz_albumid", "ce40cdb1-a562-4fd8-a269-9269f98d4124"), + ("mbid", "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), + ("musicbrainz_artistid", "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), + ("musicbrainz_albumartistid", "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), ("license", "https://creativecommons.org/licenses/by-nc-nd/2.5/"), ("copyright", "Someone"), ], @@ -144,7 +137,7 @@ def test_can_get_metadata_from_id3_mp3_file(field, value): path = os.path.join(DATA_DIR, "test.mp3") data = metadata.Metadata(path) - assert data.get(field) == value + assert str(data.get(field)) == value @pytest.mark.parametrize( @@ -170,16 +163,13 @@ def test_can_get_pictures(name): ("artist", "Nine Inch Nails"), ("album_artist", "Nine Inch Nails"), ("album", "The Slip"), - ("date", datetime.date(2008, 5, 5)), - ("track_number", 1), - ("disc_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"), - ), + ("date", "2008-05-05"), + ("position", "1"), + ("disc_number", "1"), + ("musicbrainz_albumid", "12b57d46-a192-499e-a91f-7da66790a1c1"), + ("mbid", "30f3f33e-8d0c-4e69-8539-cbd701d18f28"), + ("musicbrainz_artistid", "b7ffd2af-418f-4be2-bdd1-22f8b48613da"), + ("musicbrainz_albumartistid", "b7ffd2af-418f-4be2-bdd1-22f8b48613da"), ("license", "http://creativecommons.org/licenses/by-nc-sa/3.0/us/"), ("copyright", "2008 nin"), ], @@ -199,54 +189,18 @@ def test_can_get_metadata_from_flac_file_not_crash_if_empty(): data.get("test") -@pytest.mark.parametrize( - "field_name", - [ - "musicbrainz_artistid", - "musicbrainz_albumid", - "musicbrainz_recordingid", - "musicbrainz_albumartistid", - ], -) -def test_mbid_clean_keeps_only_first(field_name): - u1 = str(uuid.uuid4()) - u2 = str(uuid.uuid4()) - field = metadata.VALIDATION[field_name] - result = field.to_python("/".join([u1, u2])) - - assert str(result) == u1 - - @pytest.mark.parametrize( "raw,expected", [ ("2017", datetime.date(2017, 1, 1)), ("2017-12-31", datetime.date(2017, 12, 31)), ("2017-14-01 01:32", datetime.date(2017, 1, 14)), # deezer format + ("2017-02", datetime.date(2017, 1, 1)), # weird format that exists + ("nonsense", None), ], ) def test_date_parsing(raw, expected): - assert metadata.get_date(raw) == expected - - -def test_date_parsing_failure(): - with pytest.raises(metadata.ParseError): - metadata.get_date("noop") - - -def test_metadata_all_ignore_parse_errors_true(mocker): - path = os.path.join(DATA_DIR, "sample.flac") - data = metadata.Metadata(path) - mocker.patch.object(data, "get", side_effect=metadata.ParseError("Failure")) - assert data.all()["date"] is None - - -def test_metadata_all_ignore_parse_errors_false(mocker): - path = os.path.join(DATA_DIR, "sample.flac") - data = metadata.Metadata(path) - mocker.patch.object(data, "get", side_effect=metadata.ParseError("Failure")) - with pytest.raises(metadata.ParseError): - data.all(ignore_parse_errors=False) + assert metadata.PermissiveDateField().to_internal_value(raw) == expected def test_metadata_fallback_ogg_theora(mocker): @@ -264,3 +218,247 @@ def test_metadata_fallback_ogg_theora(mocker): assert data.get("pictures", "default") == expected_result fallback_get.assert_called_once_with("pictures", "default") + + +@pytest.mark.parametrize( + "path, expected", + [ + ( + "test.mp3", + { + "title": "Bend", + "artists": [ + { + "name": "Binärpilot", + "mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), + } + ], + "album": { + "title": "You Can't Stop Da Funk", + "mbid": uuid.UUID("ce40cdb1-a562-4fd8-a269-9269f98d4124"), + "release_date": datetime.date(2006, 2, 7), + "artists": [ + { + "name": "Binärpilot", + "mbid": uuid.UUID("9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"), + } + ], + }, + "position": 2, + "disc_number": 1, + "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), + "license": "https://creativecommons.org/licenses/by-nc-nd/2.5/", + "copyright": "Someone", + }, + ), + ( + "test.ogg", + { + "title": "Peer Gynt Suite no. 1, op. 46: I. Morning", + "artists": [ + { + "name": "Edvard Grieg", + "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + } + ], + "album": { + "title": "Peer Gynt Suite no. 1, op. 46", + "mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), + "release_date": datetime.date(2012, 8, 15), + "artists": [ + { + "name": "Edvard Grieg", + "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + }, + { + "name": "Musopen Symphony Orchestra", + "mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"), + }, + ], + }, + "position": 1, + "disc_number": 1, + "mbid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"), + "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", + "copyright": "Someone", + }, + ), + ( + "test.opus", + { + "title": "Peer Gynt Suite no. 1, op. 46: I. Morning", + "artists": [ + { + "name": "Edvard Grieg", + "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + } + ], + "album": { + "title": "Peer Gynt Suite no. 1, op. 46", + "mbid": uuid.UUID("a766da8b-8336-47aa-a3ee-371cc41ccc75"), + "release_date": datetime.date(2012, 8, 15), + "artists": [ + { + "name": "Edvard Grieg", + "mbid": uuid.UUID("013c8e5b-d72a-4cd3-8dee-6c64d6125823"), + }, + { + "name": "Musopen Symphony Orchestra", + "mbid": uuid.UUID("5b4d7d2d-36df-4b38-95e3-a964234f520f"), + }, + ], + }, + "position": 1, + "disc_number": 1, + "mbid": uuid.UUID("bd21ac48-46d8-4e78-925f-d9cc2a294656"), + "license": "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/", + "copyright": "Someone", + }, + ), + ( + "test_theora.ogg", + { + "title": "Drei Kreuze (dass wir hier sind)", + "artists": [ + { + "name": "Die Toten Hosen", + "mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + } + ], + "album": { + "title": "Ballast der Republik", + "mbid": uuid.UUID("1f0441ad-e609-446d-b355-809c445773cf"), + "release_date": datetime.date(2012, 5, 4), + "artists": [ + { + "name": "Die Toten Hosen", + "mbid": uuid.UUID("c3bc80a6-1f4a-4e17-8cf0-6b1efe8302f1"), + } + ], + }, + "position": 1, + "disc_number": 1, + "mbid": uuid.UUID("124d0150-8627-46bc-bc14-789a3bc960c8"), + # somehow, I cannot successfully create an ogg theora file + # with the proper license field + # ("license", "Dummy license: http://creativecommons.org/licenses/by-sa/4.0/"), + "copyright": "℗ 2012 JKP GmbH & Co. KG", + }, + ), + ( + "sample.flac", + { + "title": "999,999", + "artists": [ + { + "name": "Nine Inch Nails", + "mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), + } + ], + "album": { + "title": "The Slip", + "mbid": uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1"), + "release_date": datetime.date(2008, 5, 5), + "artists": [ + { + "name": "Nine Inch Nails", + "mbid": uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da"), + } + ], + }, + "position": 1, + "disc_number": 1, + "mbid": uuid.UUID("30f3f33e-8d0c-4e69-8539-cbd701d18f28"), + "license": "http://creativecommons.org/licenses/by-nc-sa/3.0/us/", + "copyright": "2008 nin", + }, + ), + ], +) +def test_track_metadata_serializer(path, expected, mocker): + path = os.path.join(DATA_DIR, path) + data = metadata.Metadata(path) + get_picture = mocker.patch.object(data, "get_picture") + expected["cover_data"] = get_picture.return_value + + serializer = metadata.TrackMetadataSerializer(data=data) + assert serializer.is_valid(raise_exception=True) is True + assert serializer.validated_data == expected + + get_picture.assert_called_once_with("cover_front", "other") + + +@pytest.mark.parametrize( + "raw, expected", + [ + ( + { + "names": "Hello; World", + "mbids": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb; f269d497-1cc0-4ae4-a0c4-157ec7d73fcd ", + }, + [ + { + "name": "Hello", + "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), + }, + { + "name": "World", + "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"), + }, + ], + ), + ( + { + "names": "Hello; World; Foo", + "mbids": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb; f269d497-1cc0-4ae4-a0c4-157ec7d73fcd ", + }, + [ + { + "name": "Hello", + "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcb"), + }, + { + "name": "World", + "mbid": uuid.UUID("f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"), + }, + {"name": "Foo", "mbid": None}, + ], + ), + ], +) +def test_artists_cleaning(raw, expected): + field = metadata.ArtistField() + assert field.to_internal_value(raw) == expected + + +@pytest.mark.parametrize( + "data, errored_field", + [ + ({"name": "Hello", "mbid": "wrong-uuid"}, "mbid"), # wrong uuid + ({"name": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, "name"), + ], +) +def test_artist_serializer_validation(data, errored_field): + serializer = metadata.ArtistSerializer(data=data) + assert serializer.is_valid() is False + + assert len(serializer.errors) == 1 + assert errored_field in serializer.errors + + +@pytest.mark.parametrize( + "data, errored_field", + [ + ({"title": "Hello", "mbid": "wrong"}, "mbid"), # wrong uuid + ( + {"title": "", "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcd"}, + "title", + ), # empty title + ], +) +def test_album_serializer_validation(data, errored_field): + serializer = metadata.AlbumSerializer(data=data) + assert serializer.is_valid() is False + + assert len(serializer.errors) == 1 + assert errored_field in serializer.errors diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 2ba95209a4..d2bb3a9035 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -20,15 +20,13 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_can_create_track_from_file_metadata_no_mbid(db, mocker): metadata = { "title": "Test track", - "artist": "Test artist", - "album": "Test album", - "date": datetime.date(2012, 8, 15), - "track_number": 4, + "artists": [{"name": "Test artist"}], + "album": {"title": "Test album", "release_date": datetime.date(2012, 8, 15)}, + "position": 4, "disc_number": 2, "license": "Hello world: http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "2018 Someone", } - mocker.patch("funkwhale_api.music.metadata.Metadata.all", return_value=metadata) match_license = mocker.spy(licenses, "match") track = tasks.get_track_from_import_metadata(metadata) @@ -39,10 +37,10 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker): assert track.disc_number == 2 assert track.license.code == "cc-by-sa-4.0" assert track.copyright == metadata["copyright"] - assert track.album.title == metadata["album"] + assert track.album.title == metadata["album"]["title"] assert track.album.mbid is None assert track.album.release_date == datetime.date(2012, 8, 15) - assert track.artist.name == metadata["artist"] + assert track.artist.name == metadata["artists"][0]["name"] assert track.artist.mbid is None match_license.assert_called_once_with(metadata["license"], metadata["copyright"]) @@ -50,33 +48,38 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker): def test_can_create_track_from_file_metadata_mbid(factories, mocker): 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", + "artists": [ + {"name": "Test artist", "mbid": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13"} + ], + "album": { + "title": "Test album", + "release_date": datetime.date(2012, 8, 15), + "mbid": "9c6bddde-6478-4d9f-ad0d-03f6fcb19e15", + "artists": [ + { + "name": "Test album artist", + "mbid": "9c6bddde-6478-4d9f-ad0d-03f6fcb19e13", + } + ], + }, + "position": 4, + "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", "cover_data": {"content": b"image_content", "mimetype": "image/png"}, } - 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.mbid == metadata["mbid"] assert track.position == 4 assert track.disc_number is None - 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.title == metadata["album"]["title"] + assert track.album.mbid == metadata["album"]["mbid"] + assert track.album.artist.mbid == metadata["album"]["artists"][0]["mbid"] + assert track.album.artist.name == metadata["album"]["artists"][0]["name"] assert track.album.release_date == datetime.date(2012, 8, 15) - assert track.artist.name == metadata["artist"] - assert track.artist.mbid == metadata["musicbrainz_artistid"] + assert track.artist.name == metadata["artists"][0]["name"] + assert track.artist.mbid == metadata["artists"][0]["mbid"] def test_can_create_track_from_file_metadata_mbid_existing_album_artist( @@ -85,22 +88,21 @@ def test_can_create_track_from_file_metadata_mbid_existing_album_artist( artist = factories["music.Artist"]() album = factories["music.Album"]() metadata = { - "artist": "", - "album": "", + "album": { + "mbid": album.mbid, + "title": "", + "artists": [{"name": "", "mbid": album.artist.mbid}], + }, "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, + "position": 4, + "artists": [{"mbid": artist.mbid, "name": ""}], + "mbid": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", } - 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.mbid == metadata["mbid"] assert track.position == 4 assert track.album == album assert track.artist == artist @@ -112,18 +114,17 @@ def test_can_create_track_from_file_metadata_fid_existing_album_artist( artist = factories["music.Artist"]() album = factories["music.Album"]() metadata = { - "artist": "", - "album": "", + "artists": [{"name": "", "fid": artist.fid}], + "album": { + "title": "", + "fid": album.fid, + "artists": [{"name": "", "fid": album.artist.fid}], + }, "title": "Hello", - "track_number": 4, + "position": 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"] @@ -139,13 +140,11 @@ def test_can_create_track_from_file_metadata_distinct_release_mbid(factories): album = factories["music.Album"](artist=artist) track = factories["music.Track"](album=album, artist=artist) metadata = { - "artist": artist.name, - "album": album.title, + "artists": [{"name": artist.name, "mbid": artist.mbid}], + "album": {"title": album.title, "mbid": str(uuid.uuid4())}, "title": track.title, - "track_number": 4, + "position": 4, "fid": "https://hello", - "musicbrainz_artistid": artist.mbid, - "musicbrainz_albumid": str(uuid.uuid4()), } new_track = tasks.get_track_from_import_metadata(metadata) @@ -162,12 +161,10 @@ def test_can_create_track_from_file_metadata_distinct_position(factories): album = factories["music.Album"](artist=artist) track = factories["music.Track"](album=album, artist=artist) metadata = { - "artist": artist.name, - "album": album.title, + "artists": [{"name": artist.name, "mbid": artist.mbid}], + "album": {"title": album.title, "mbid": album.mbid}, "title": track.title, - "track_number": track.position + 1, - "musicbrainz_artistid": artist.mbid, - "musicbrainz_albumid": album.mbid, + "position": track.position + 1, } new_track = tasks.get_track_from_import_metadata(metadata) @@ -177,23 +174,28 @@ def test_can_create_track_from_file_metadata_distinct_position(factories): def test_can_create_track_from_file_metadata_federation(factories, mocker, r_mock): metadata = { - "artist": "Artist", - "album": "Album", - "album_artist": "Album artist", + "artists": [ + {"name": "Artist", "fid": "https://artist.fid", "fdate": timezone.now()} + ], + "album": { + "title": "Album", + "fid": "https://album.fid", + "fdate": timezone.now(), + "artists": [ + { + "name": "Album artist", + "fid": "https://album.artist.fid", + "fdate": timezone.now(), + } + ], + }, "title": "Hello", - "track_number": 4, + "position": 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, update_cover=True) @@ -203,15 +205,15 @@ def test_can_create_track_from_file_metadata_federation(factories, mocker, r_moc 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"] + assert track.album.fid == metadata["album"]["fid"] + assert track.album.title == metadata["album"]["title"] + assert track.album.creation_date == metadata["album"]["fdate"] + assert track.album.artist.fid == metadata["album"]["artists"][0]["fid"] + assert track.album.artist.name == metadata["album"]["artists"][0]["name"] + assert track.album.artist.creation_date == metadata["album"]["artists"][0]["fdate"] + assert track.artist.fid == metadata["artists"][0]["fid"] + assert track.artist.name == metadata["artists"][0]["name"] + assert track.artist.creation_date == metadata["artists"][0]["fdate"] def test_sort_candidates(factories): @@ -391,7 +393,38 @@ def test_upload_import_error(factories, now, temp_signal): assert upload.import_status == "errored" assert upload.import_date == now - assert upload.import_details == {"error_code": "track_uuid_not_found"} + assert upload.import_details == { + "error_code": "track_uuid_not_found", + "detail": None, + } + handler.assert_called_once_with( + upload=upload, + old_status="pending", + new_status="errored", + sender=None, + signal=signals.upload_import_status_updated, + ) + + +def test_upload_import_error_metadata(factories, now, temp_signal, mocker): + path = os.path.join(DATA_DIR, "test.ogg") + upload = factories["music.Upload"](audio_file__frompath=path) + mocker.patch.object( + metadata.AlbumField, + "to_internal_value", + side_effect=metadata.serializers.ValidationError("Hello"), + ) + with temp_signal(signals.upload_import_status_updated) as handler: + tasks.process_upload(upload_id=upload.pk) + upload.refresh_from_db() + + assert upload.import_status == "errored" + assert upload.import_date == now + assert upload.import_details == { + "error_code": "invalid_metadata", + "detail": {"album": ["Hello"]}, + "file_metadata": metadata.Metadata(path).all(), + } handler.assert_called_once_with( upload=upload, old_status="pending", @@ -494,31 +527,43 @@ def test_federation_audio_track_to_metadata(now): 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"], + "position": payload["position"], "disc_number": payload["disc"], "license": "http://creativecommons.org/licenses/by-sa/4.0/", "copyright": "2018 Someone", + "mbid": payload["musicbrainzId"], + "fdate": serializer.validated_data["published"], + "fid": payload["id"], + "album": { + "title": payload["album"]["name"], + "release_date": released, + "mbid": payload["album"]["musicbrainzId"], + "fid": payload["album"]["id"], + "fdate": serializer.validated_data["album"]["published"], + "artists": [ + { + "name": a["name"], + "mbid": a["musicbrainzId"], + "fid": a["id"], + "fdate": serializer.validated_data["album"]["artists"][i][ + "published" + ], + } + for i, a in enumerate(payload["album"]["artists"]) + ], + }, # 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" + "artists": [ + { + "name": a["name"], + "mbid": a["musicbrainzId"], + "fid": a["id"], + "fdate": serializer.validated_data["artists"][i]["published"], + } + for i, a in enumerate(payload["artists"]) ], - "album_fdate": serializer.validated_data["album"]["published"], "cover_data": { "mimetype": serializer.validated_data["album"]["cover"]["mediaType"], "url": serializer.validated_data["album"]["cover"]["href"], @@ -528,10 +573,6 @@ def test_federation_audio_track_to_metadata(now): 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"]() diff --git a/changes/changelog.d/252.feature b/changes/changelog.d/252.feature new file mode 100644 index 0000000000..4bbb2da1e5 --- /dev/null +++ b/changes/changelog.d/252.feature @@ -0,0 +1 @@ +Improved error handling and display during import (#252, #718, #583, #501, #544) diff --git a/changes/changelog.d/718.bugfix b/changes/changelog.d/718.bugfix new file mode 100644 index 0000000000..4dd1e13fef --- /dev/null +++ b/changes/changelog.d/718.bugfix @@ -0,0 +1 @@ +Fixed crashing upload processing on invalid date format (#718) diff --git a/changes/notes.rst b/changes/notes.rst index 8f5ee15f7b..858ed5eebc 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -35,6 +35,15 @@ enabled in a future release). If you want to start building an app on top of Funkwhale's API, please check-out `https://docs.funkwhale.audio/api.html`_ and `https://docs.funkwhale.audio/developers/authentication.html`_. +Better error handling and display during import +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Funkwhale should now be more resilient to missing tags in imported files, and give +you more insights when something goes wrong, including the specific tags that were missing +or invalid, and additional debug information to share in your support requests. + +This information is available in all pages that list uploads, when clicking on the button next to the upload status. + Prune library command ^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/users/upload.rst b/docs/users/upload.rst index 1dfa4d76fb..169c72710f 100644 --- a/docs/users/upload.rst +++ b/docs/users/upload.rst @@ -154,3 +154,30 @@ Then select the files you want to delete using the checkboxes on the left ; you Finally, select "Delete" in the "Action" menu and click "Go". This operation does *not* remove metadata, meaning that deleted tracks will remain visible in your library. They just won't be playable anymore. + + +Common errors during import +--------------------------- + +.. _invalid_metadata: + +Invalid metadata +:::::::::::::::: + +This error occurs when the uploaded file miss some mandatory tags, or when some tags have +incorrect values (e.g an empty title or artist name). + +To fix this issue, please retag the file properly as described in :ref:`upload-tagging` +and reupload it. + + +.. _unknown_error: + +Unkwown error +::::::::::::: + +This error can happen for multiple reasons and likely indicates an issue with the Funkwhale +server (e.g. misconfiguration) or with Funkwhale itself. + +If the issue persists when you relaunch the import, get in touch with our instance admin +or open a support thread on our forum. diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index e630373a2e..f6c4e69efa 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -35,6 +35,88 @@ </div> </div> </div> + <modal :show.sync="showUploadDetailModal"> + <div class="header"> + <translate translate-context="Popup/Import/Title">Import detail</translate> + </div> + <div class="content" v-if="detailedUpload"> + <div class="description"> + <div class="ui message" v-if="detailedUpload.import_status === 'pending'"> + <translate translate-context="Popup/Import/Message">Upload is still pending and will soon be processed by the server.</translate> + </div> + <div class="ui success message" v-if="detailedUpload.import_status === 'finished'"> + <translate translate-context="Popup/Import/Message">Upload was successfully processed by the server.</translate> + </div> + <div class="ui warning message" v-if="detailedUpload.import_status === 'skipped'"> + <translate translate-context="Popup/Import/Message">Upload was skipped because a similar one is already available in one of your libraries.</translate> + </div> + <div class="ui error message" v-if="detailedUpload.import_status === 'errored'"> + <translate translate-context="Popup/Import/Message">An error occured during upload processing. You will find more information below.</translate> + </div> + <template v-if="detailedUpload.import_status === 'errored'"> + <table class="ui very basic collapsing celled table"> + <tbody> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Error type</translate> + </td> + <td> + {{ getErrorData(detailedUpload).label }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Error detail</translate> + </td> + <td> + {{ getErrorData(detailedUpload).detail }} + <ul v-if="getErrorData(detailedUpload).errorRows.length > 0"> + <li v-for="row in getErrorData(detailedUpload).errorRows"> + {{ row.key}}: {{ row.value}} + </li> + </ul> + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Getting help</translate> + </td> + <td> + <ul> + <li> + <a :href="getErrorData(detailedUpload).documentationUrl" target="_blank"> + <translate translate-context="Popup/Import/Table.Label/Value">Read our documentation for this error</translate> + </a> + </li> + <li> + <a :href="getErrorData(detailedUpload).supportUrl" target="_blank"> + <translate translate-context="Popup/Import/Table.Label/Value">Open a support thread (include the debug information below in your message)</translate> + </a> + </li> + </ul> + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Debug information</translate> + </td> + <td> + <div class="ui form"> + <textarea class="ui textarea" rows="10" :value="getErrorData(detailedUpload).debugInfo"></textarea> + </div> + </td> + </tr> + </tbody> + </table> + </template> + </div> + </div> + <div class="actions"> + <div class="ui deny button"> + <translate translate-context="*/*/Button.Label/Verb">Close</translate> + </div> + </div> + </modal> <div class="dimmable"> <div v-if="isLoading" class="ui active inverted dimmer"> <div class="ui loader"></div> @@ -80,11 +162,13 @@ <td> <human-date :date="scope.obj.creation_date"></human-date> </td> - <td :title="labels.importStatuses[scope.obj.import_status].help"> - <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)"> + <td> + <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)" :title="labels.importStatuses[scope.obj.import_status].help"> {{ labels.importStatuses[scope.obj.import_status].label }} - <i class="question circle outline icon"></i> </span> + <button class="ui tiny basic icon button" :title="labels.statusDetailTitle" @click="detailedUpload = scope.obj; showUploadDetailModal = true"> + <i class="question circle outline icon"></i> + </button> </td> <td v-if="scope.obj.duration"> {{ time.parse(scope.obj.duration) }} @@ -132,7 +216,33 @@ import ActionTable from '@/components/common/ActionTable' import OrderingMixin from '@/components/mixins/Ordering' import TranslationsMixin from '@/components/mixins/Translations' import SmartSearchMixin from '@/components/mixins/SmartSearch' +import Modal from '@/components/semantic/Modal' +function getErrors(payload) { + let errors = [] + for (var k in payload) { + if (payload.hasOwnProperty(k)) { + let value = payload[k] + if (Array.isArray(value)) { + errors.push({ + key: k, + value: value.join(', ') + }) + } else { + // possibly artists, so nested errors + if (typeof value === 'object') { + getErrors(value).forEach((e) => { + errors.push({ + key: `${k} / ${e.key}`, + value: e.value + }) + }) + } + } + } + } + return errors +} export default { mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], props: { @@ -142,11 +252,14 @@ export default { }, components: { Pagination, - ActionTable + ActionTable, + Modal }, data () { return { time, + detailedUpload: null, + showUploadDetailModal: false, isLoading: false, result: null, page: 1, @@ -193,12 +306,41 @@ export default { }, selectPage: function (page) { this.page = page + }, + getErrorData (upload) { + let payload = upload.import_details || {} + let d = { + supportUrl: 'https://governance.funkwhale.audio/g/246YOJ1m/funkwhale-support', + errorRows: [] + } + if (!payload.error_code) { + d.errorCode = 'unknown_error' + } else { + d.errorCode = payload.error_code + } + d.documentationUrl = `https://docs.funkwhale.audio/users/upload.html#${d.errorCode}` + if (d.errorCode === 'invalid_metadata') { + d.label = this.$pgettext('Popup/Import/Error.Label', 'Invalid metadata') + d.detail = this.$pgettext('Popup/Import/Error.Label', 'The metadata included in the file is invalid or some mandatory fields are missing.') + let detail = payload.detail || {} + d.errorRows = getErrors(detail) + } else { + d.label = this.$pgettext('Popup/Import/Error.Label', 'Unkwown error') + d.detail = this.$pgettext('Popup/Import/Error.Label', 'An unkwown error occured') + } + let debugInfo = { + source: upload.source, + ...payload, + } + d.debugInfo = JSON.stringify(debugInfo, null, 4) + return d } }, computed: { labels () { return { searchPlaceholder: this.$pgettext('Content/Library/Input.Placeholder', 'Search by title, artist, album…'), + statusDetailTitle: this.$pgettext('Content/Library/Link.Title', 'Click to display more information about the import process for this upload'), importStatuses: { skipped: { label: this.$pgettext('Content/Library/*', 'Skipped'), -- GitLab