diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b095d7a..3ef4e93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ The back-end is mostly centered around two modules containing the logic for segm 2. ``backend/api/app.py`` is the main entrypoint for Flask. Error handlers and other API modules are registered here. 3. ``backend/api/audio.py`` contains the API routes for song segmentation, recognition and saving. 4. ``backend/modules/segmentation.py`` implements the segmentation logic. Relevant concepts to understand it are [Feature Smoothing](https://www.audiolabs-erlangen.de/resources/MIR/FMP/C4/C4S2_SSM-FeatureSmoothing.html), [Self-similarity-matrices](https://www.audiolabs-erlangen.de/resources/MIR/FMP/C4/C4S2_SSM.html), [Novelty](https://www.audiolabs-erlangen.de/resources/MIR/FMP/C4/C4S4_NoveltySegmentation.html) and [Peak Selection](https://www.audiolabs-erlangen.de/resources/MIR/FMP/C6/C6S1_PeakPicking.html) -5. ``backend/modules/api_service.py`` implements the song recognition. Each song identification API used has either a pre-made python wrapper (such as AcoustID with PyAcoustId) or its own module in ``backend/modules/apis/`` (such as Shazam), which ``api_service`` calls to gather data from that API. +5. ``backend/modules/api_service.py`` implements the song recognition. Each song identification API used has its own module in ``backend/modules/apis/``, which ``api_service`` calls to gather data from that API. Other modules in the ``backend/modules`` and ``backend/utils`` folders are utility classes used in or for one of the above. The other routes in the ``backend/api`` folder are used for user settings. diff --git a/README.MD b/README.MD index 6758ed3..9ecb1ce 100644 --- a/README.MD +++ b/README.MD @@ -18,7 +18,7 @@ For information on how to contribute to the development of AudioStreamSplitting, ### Setup -1. Generate a valid API key for the [AcoustID](https://acoustid.org/) and/or [Shazam](https://rapidapi.com/apidojo/api/shazam) song recognition APIs. While this program can work if you only provide one of the two, it is strongly recommended to have both in order for the song identification to function properly. More song recognition APIs may be supported in the future. +1. Generate a valid API key for the [AcoustID](https://acoustid.org/) and/or [Shazam](https://rapidapi.com/apidojo/api/shazam) song recognition APIs. While this program can work if you only provide one of the two, it is strongly recommended to have both in order for the song identification to function properly. More song recognition APIs may be supported in the future. For the AcoustID API, you will need to register an application and use the application API key, rather than just the normal user API key. 2. Download the latest release of [`fpcalc`](https://acoustid.org/chromaprint) for your system from [the AcoustID website](https://acoustid.org/chromaprint) if you want to use the AcoustID API. Put it in a location of your choosing and add it to your system PATH. 3. Download the latest release of [`ffmpeg`](https://ffmpeg.org/download.html) if you want to use the in-app recording feature. Put it in a location of your choosing and add it to your system PATH. 4. If `ffmpeg` or `fpcalc` were not installed on your system prior to setup, you will probably need to restart your system first before using AudioStreamSplitting. diff --git a/src/backend/api/api.yaml b/src/backend/api/api.yaml index 8db7123..1f407ed 100644 --- a/src/backend/api/api.yaml +++ b/src/backend/api/api.yaml @@ -128,7 +128,13 @@ components: type: string album: type: string + albumartist: + type: string artist: type: string year: type: string + isrc: + type: string + genre: + type: string diff --git a/src/backend/modules/api_service.py b/src/backend/modules/api_service.py index 297ce71..595849b 100644 --- a/src/backend/modules/api_service.py +++ b/src/backend/modules/api_service.py @@ -1,15 +1,15 @@ -import os from enum import Enum from typing import Generator -import acoustid from acoustid import FingerprintGenerationError, NoBackendError, WebServiceError +from modules.apis.acoustid import create_fingerprint as create_acoustid_fingerprint +from modules.apis.acoustid import lookup as acoustid_lookup +from modules.apis.shazam import lookup as shazam_lookup +from modules.audio_stream_io import read_audio_file_to_numpy +from requests import exceptions from utils.env import get_env from utils.logger import log_error -from .apis.shazam import lookup as shazam_lookup -from .audio_stream_io import read_audio_file_to_numpy, save_numpy_as_audio_file - class SongOptionResult(Enum): """``SongOptionResult`` contains information about the state of the API service. @@ -71,7 +71,18 @@ class ApiService: the current song is stored in the ``last_song_*`` attributes and can be retrieved using ``get_last_song``. - The workflow of using the API service, as implemented in ``identify_all_from_generator`` + The specific metadata that can be retrieved depends on the API that identified the song. The + following metadata can be retrieved by at least one of the supported APIs: + + * artist + * title + * album + * albumartist + * year + * isrc + * genre + + The workflow of using the API service, as implemented in ``identify_all_from_generator``, should look as follows:: import modules.api_service @@ -178,6 +189,15 @@ def identify_all_from_generator( If ``get_song_options`` returns ``SongOptionResult.SONG_MISMATCH``, the segment's offset is additionally written to the ``mismatch_offsets`` list. + The following metadata can potentially be retrieved: + * title + * artist + * album + * albumartist + * year + * isrc + * genre + :param generator: A generator (returned by ``modules.segmentation``) that provides tuples of song data as (offset: float, duration: float). :param file_path: The path to the analyzed file. @@ -219,6 +239,15 @@ def get_last_song(self): This should be called after a song is finished, except for the first time (as it will then contain empty metadata). + The following metadata can potentially be retrieved: + * title + * artist + * album + * albumartist + * year + * isrc + * genre + :returns: A dict with the keys ``"offset"`` for the segment start, ``"duration"`` for the segment duration and ``"metadataOptions"`` for the metadata options. @@ -253,6 +282,15 @@ def get_final_song(self): This should be called after calling ``get_song_options`` for the last time for a file as the very last call to an ``ApiService`` instance. + The following metadata can potentially be retrieved: + * title + * artist + * album + * albumartist + * year + * isrc + * genre + :returns: A dict with the keys ``"offset"`` for the segment start, ``"duration"`` for the segment duration and ``"metadataOptions"`` for the metadata options. @@ -355,8 +393,10 @@ def get_song_options(self, offset: float, duration: float, file_path: str): # first check using acoustID if ACOUSTID_API_KEY is not None: try: - duration, fingerprint = self._create_fingerprint(song_data, sample_rate) - metadata = self._get_api_song_data_acoustid(fingerprint, duration) + duration, fingerprint = create_acoustid_fingerprint( + song_data, sample_rate + ) + metadata = acoustid_lookup(fingerprint, duration, ACOUSTID_API_KEY) if len(metadata) != 0: return self._check_song_extended_or_finished( offset, duration, metadata @@ -389,6 +429,8 @@ def get_song_options(self, offset: float, duration: float, file_path: str): return SongOptionResult.SONG_MISMATCH except ConnectionError as ex: log_error(ex, "Shazam connection error") + except exceptions.ReadTimeout as ex: + log_error(ex, "Shazam request timed out") # if neither finds anything, song not recognised. self._store_finished_song(offset, duration, ()) @@ -400,6 +442,11 @@ def _check_song_extended_or_finished( """Check if metadata options of the analyzed segment match those of the previous segment. Store the finished song if applicable. + This check only accounts for differences in artist and title - if the analyzed and previous + segment have metadata options with matching artists and titles, the corresponding metadata + options from the previous segment are used, even if that means discarding metadata that was + loaded for the current but not the previous segment. + :param offset: The offset at which the segment begins, in seconds. :param duration: The duration of the segment, in seconds. :param metadata_options: A list of the metadata options for the analyzed segment as dicts. @@ -422,6 +469,7 @@ def _check_song_extended_or_finished( def _get_overlapping_metadata_values(self, metadata1, metadata2): """From two lists of metadata, get all that have the same artist and title. If either of the lists is empty, return the other list. + If metadata other than artist and title mismatch, the metadata from metadata1 are used, even if that means discarding data that is empty in metadata1 and set in metadata2. @@ -457,82 +505,6 @@ def _get_overlapping_metadata_values(self, metadata1, metadata2): overlapping_metadata.append(metadata) return overlapping_metadata - def _create_fingerprint(self, song_data, samplerate): - """Create a chromaprint/AcoustID fingerprint for the given audio data - in order to identify it using AcoustID. - As of current, this works by writing the data to a temporary file - and using the fpcalc command line tool to generate the fingerprint. - The temporary file is deleted immediately afterwards. - - TODO: If it becomes feasible to build and distribute DLL versions of chromaprint - for all target platforms, this should be refactored to use that instead. - - :param song_data: the audio data to generate a fingerprint from. - :param samplerate: the audio data's sample rate. - :returns: (song_duration, fingerprint). - ``song_duration`` is measured in seconds and used for the API call to AcoustID. - ``fingerprint`` is generated by fpcalc. - :raise acoustid.NoBackendError: if fpcalc is not installed. - :raise acoustid.FingerprintGenerationError: if fingerprint generation fails. - """ - filename = "TEMP_FILE_FOR_FINGERPRINTING" - save_numpy_as_audio_file( - song_data, os.path.abspath(filename), "", rate=samplerate - ) - - filename_with_path = os.path.abspath(filename + ".mp3") - fingerprint_duration, fingerprint = acoustid.fingerprint_file( - filename_with_path, force_fpcalc=True - ) - os.remove(filename_with_path) - return (fingerprint_duration, fingerprint) - - def _get_api_song_data_acoustid(self, fingerprint, fingerprint_duration): - """Get data about the provided fingerprint from the AcoustID API. - This uses the ``pyacoustid`` library as a wrapper, which will only return the song's title - and artist, as well as a match score and the MusicBrainz ID, - although those are discarded as they have no further use. - - TODO: This should be enhanced to include a second call to the AcoustID API - that gathers more metadata for the song using the MusicBrainz ID. - - :param fingerprint: the fingerprint generated using ``_create_fingerprint``. - :param fingerprint_duration: duration of the fingerprinted data, in seconds. - :returns: A list of dicts containing the results. - The dicts have the keys ``"artist"`` for the artist name - and ``"title"`` for the song title. - - Example:: - - [ - { - "title": "Thunderstruck", - "artist": "AC/DC", - }, - { - "title": "Thunderstruck", - "artist": "2Cellos" - } - ] - :raise acoustid.WebServiceError: if the request fails. - """ - ACOUSTID_API_KEY = get_env("SERVICE_ACOUSTID_API_KEY") - - try: - result = [] - for score, recording_id, title, artist in acoustid.parse_lookup_result( - acoustid.lookup(ACOUSTID_API_KEY, fingerprint, fingerprint_duration) - ): - if ( - title is not None - and artist is not None - and {"title": title, "artist": artist} not in result - ): - result.append({"title": title, "artist": artist}) - return result - except acoustid.WebServiceError: - return [] - def _store_finished_song(self, offset: float, duration: float, metadata_options): """Store the current (finished) data in the ``last_song_*`` variables. Store the provided data in the ``current_song_*`` variables. diff --git a/src/backend/modules/apis/acoustid.py b/src/backend/modules/apis/acoustid.py new file mode 100644 index 0000000..015290b --- /dev/null +++ b/src/backend/modules/apis/acoustid.py @@ -0,0 +1,283 @@ +""" +This module provides functionality to access the AcoustID API (https://acoustid.org) +to identify a segment of song data. AcoustID will only recognise a segment if it is a full +song. + +Requests and fingerprinting are performed using the ``acoustid`` library, so exceptions raised by +this module will use the exception classes from ``acoustid``. +""" + +import os + +import acoustid +import utils.list_helper +from modules.audio_stream_io import save_numpy_as_audio_file + +METADATA_ALL = ["tracks", "recordings", "releasegroups"] +"""The metadata to query from the AcoustID API. + * "tracks" offers the track title. + * "recordings" offers the track artist. + * "releasegroups" offers the albums the track was published on. + + As of current, only three sets of metadata can be requested, if four or more are sent, + some are discarded. If this limitation is ever removed, this should also add the "recordingids" + metadata to make merging matching recordings easier. +""" + + +def create_fingerprint(song_data, samplerate): + """Create a chromaprint/AcoustID fingerprint for the given audio data + in order to identify it using AcoustID. + As of current, this works by writing the data to a temporary file + and using the fpcalc command line tool to generate the fingerprint. + The temporary file is deleted immediately afterwards. + + TODO: If it becomes feasible to build and distribute DLL versions of chromaprint + for all target platforms, this should be refactored to use that instead. + + :param song_data: the audio data to generate a fingerprint from. + :param samplerate: the audio data's sample rate. + :returns: (song_duration, fingerprint). + ``song_duration`` is measured in seconds and used for the API call to AcoustID. + ``fingerprint`` is generated by fpcalc. + :raise acoustid.NoBackendError: if fpcalc is not installed. + :raise acoustid.FingerprintGenerationError: if fingerprint generation fails. + """ + filename = "TEMP_FILE_FOR_FINGERPRINTING" + save_numpy_as_audio_file(song_data, os.path.abspath(filename), "", rate=samplerate) + + filename_with_path = os.path.abspath(filename + ".mp3") + fingerprint_duration, fingerprint = acoustid.fingerprint_file( + filename_with_path, force_fpcalc=True + ) + os.remove(filename_with_path) + return (fingerprint_duration, fingerprint) + + +def lookup(fingerprint, fingerprint_duration, api_key): + """Get data about the provided fingerprint from the AcoustID API. + This uses the ``pyacoustid`` library to make the actual API call, but parsing + it is handled through custom functions to allow retrieving more metadata. + + The following metadata can be retrieved: + * artist + * title + * album + * albumartist + + If a recording has at least one album without a secondary type (secondary types being + compilations, film soundtracks, ...), all albums with secondary types are filtered + out from the metadata options to avoid excessive clutter. + + :param fingerprint: the fingerprint generated using ``_create_fingerprint``. + :param fingerprint_duration: duration of the fingerprinted data, in seconds. + :returns: A ``list`` of ``dict`` s containing the results. + Example:: + + [ + { + "title": "Thunderstruck", + "artist": "AC/DC", + "album": "The Razor's Edge", + "albumartist": "AC/DC" + }, + { + "title": "Thunderstruck", + "artist": "2Cellos" + } + ] + :raise acoustid.WebServiceError: if the request fails. + """ + return _parse_lookup_result( + acoustid.lookup( + api_key, + fingerprint, + fingerprint_duration, + meta=METADATA_ALL, + ) + ) + + +def _parse_lookup_result(data): + """This is an extended/altered version of acoustid.parse_lookup_result. + Retrieve the song metadata from the data returned by an AcoustID API call. + Results that do not contain recordings are discarded, as they aren't useful. + + If a recording has at least one album without a secondary type (secondary types being + compilations, film soundtracks, ...), all albums with secondary types are filtered + out from the metadata options to avoid excessive clutter. + + The following metadata can be retrieved: + * title + * artist + * album + * albumartist + + :param data: The parsed JSON response from acoustid.lookup(). + :returns: A ``list`` of ``dict`` s containing metadata. + :raise acoustid.WebServiceError: if the response is incomplete or the request failed. + """ + if data["status"] != "ok": + raise acoustid.WebServiceError("status: %s" % data["status"]) + if "results" not in data: + raise acoustid.WebServiceError("results not included") + + recordings = _extract_recordings(data["results"]) + return _get_results_for_recordings(recordings) + + +def _extract_recordings(results): + """Extract all recordings from the results returned by AcoustID. + + If a recording has at least one album without a secondary type (secondary types being + compilations, film soundtracks, ...), all albums with secondary types are filtered + out from the metadata options to avoid excessive clutter. + + :param results: The "results" segment of the AcoustID response. + :returns: a ``list`` of all recordings. Recordings with the same title and set of artists are + merged and if a non-compilation releasegroup exists, all compilations are filtered out. + """ + all_recordings = utils.list_helper.flatten( + [result["recordings"] for result in results if "recordings" in result] + ) + return _merge_matching_recordings(all_recordings) + + +def _merge_matching_recordings(recordings: list): + """Merge recordings with the same title and artists. + This iterates over all recordings and merges the "releasegroups" sections of ones with the same + title and artists. + + If a recording has at least one album without a secondary type (secondary types being + compilations, film soundtracks, ...), all albums with secondary types are filtered + out from the metadata options to avoid excessive clutter. + + TODO: This should be refactored to be more pythonic and readable, if possible. + + :param recordings: a ``list`` of ``recording`` ``dict`` s as provided by the AcoustID API. + :returns: a ``list`` of ``recordings`` where matching entries were merged. + """ + grouped_by_title_and_artist = {} + artists_by_title_and_artist = {} + for recording in recordings: + if "title" not in recording or "artists" not in recording: + # no title or no artist => useless data, discard + continue + title = recording["title"] + artist_id = ",".join( + [artist.setdefault("id", "") for artist in recording["artists"]] + ) + if title not in grouped_by_title_and_artist: + grouped_by_title_and_artist[title] = {} + artists_by_title_and_artist[title] = {} + if artist_id not in grouped_by_title_and_artist[title]: + grouped_by_title_and_artist[title][artist_id] = [] + artists_by_title_and_artist[title][artist_id] = recording["artists"] + grouped_by_title_and_artist[title][artist_id] = ( + grouped_by_title_and_artist[title][artist_id] + recording["releasegroups"] + ) + return [ + { + "title": title, + "artists": artists_by_title_and_artist[title][artist_id], + "releasegroups": _filter_out_compilations_from_releasegroups( + utils.list_helper.remove_duplicate_dicts(releasegroups) + ), + } + for title, entries in grouped_by_title_and_artist.items() + for artist_id, releasegroups in entries.items() + ] + + +def _filter_out_compilations_from_releasegroups(releasegroups): + """If there is at least one album without a secondary type (such as "compilation", "soundtrack", + etc.) in ``releasegroups``, exclude all albums with one. + Otherwise, return the unfiltered list of releasegroups. + + :param releasegroups: The detected release groups. + :returns: The filtered list of release groups, or the unfiltered list if the filtered list would + be empty. + """ + filtered_releasegroups = [ + releasegroup + for releasegroup in releasegroups + if ("secondarytypes" not in releasegroup) + ] + return filtered_releasegroups if len(filtered_releasegroups) != 0 else releasegroups + + +def _get_results_for_recordings(recordings): + """Go through all the given recordings, parse their metadata into a dict and append them to the + results list. To return all possible results, go through each release group the recording is in + and append a separate result, so releases on different albums are identified separately. + + Metadata that do not contain an artist or a title are discarded, as they are not useful. + + The following metadata can be retrieved: + * artist + * title + * album + * albumartist + + :param recordings: The recordings to parse + :param results: The list to append the results to. + :returns: The list with the appended results. + """ + results = [] + for recording in recordings: + # Get the artist if available. + if "artists" not in recording or "title" not in recording: + continue + artist_name = _join_artist_names(recording["artists"]) + for releasegroup in recording["releasegroups"]: + results.append( + _get_result_for_releasegroup( + releasegroup, artist_name, recording["title"] + ) + ) + return results + + +def _get_result_for_releasegroup(releasegroup, artist_name: str, title: str): + """Convert the given release group with the given parameters into a ``dict`` containing + the metadata. + + The ``dict`` will have the following keys: + * artist + * title + * album + * albumartist + + If no album artist is set, the albumartist field will be ``None`` instead. + + :param releasegroup: The release group. + :param artist_name: The artist name. + :param title: The title. + :returns: The ``dict`` containing the parsed release group. + """ + album_artist_name = ( + _join_artist_names(releasegroup["artists"]) + if "artists" in releasegroup + else None + ) + album_title = ( + releasegroup["title"] + if "title" in releasegroup + else (releasegroup["name"] if "name" in releasegroup else None) + ) + return { + "artist": artist_name, + "title": title, + "album": album_title, + "albumartist": album_artist_name, + } + + +def _join_artist_names(artists): + """Join all artist names from the given artists list. + + :param artists: List containing all artists to join together. + :returns: The artist names, joined together with "; " as a separator. + """ + names = [artist["name"] for artist in artists] + return "; ".join(names) diff --git a/src/backend/modules/apis/shazam.py b/src/backend/modules/apis/shazam.py index e12fe90..674981d 100644 --- a/src/backend/modules/apis/shazam.py +++ b/src/backend/modules/apis/shazam.py @@ -29,12 +29,22 @@ def lookup(song_data: np.ndarray, apikey: str, from_start: bool = True): The segment size is defined by the LOOKUP_SEGMENTS_DURATION constant and set to 4 seconds, as the Shazam API expects uploaded segments to be between 3 and 5 seconds long. + The following metadata can potentially be retrieved: + + * title + * artist + * album + * year + * isrc + * genre + :param song_data: The song data. The data must be at a sample rate of 44100Hz as the Shazam API will not work with other sample rates. :param apikey: The Shazam API key. :param from_start: Whether to take a sample from the start or end of the song. :returns: the retrieved metadata as a dict, or None if no matches are found. Example:: + { "title": "Thunderstruck", "artist": "AC/DC", @@ -71,14 +81,7 @@ def lookup(song_data: np.ndarray, apikey: str, from_start: bool = True): break matches, track = _lookup_segment_with_offset(song_data, apikey, offset) if len(matches) != 0: - album = _extract_value_from_metadata(track, "Album") - year = _extract_value_from_metadata(track, "Released") - return { - "title": track["title"], - "artist": track["subtitle"], - "album": album, - "year": year, - } + return _process_lookup_response(track) return None @@ -146,21 +149,55 @@ def _send_lookup_request(payload: str, apikey: str): """Send the actual lookup request to the Shazam API. This uses the ``requests`` module to send the request and can thus raise all errors a failed ``requests.post`` can. - :param payload: The payload as generated by ``_create_payload_from_song_data` + :param payload: The payload as generated by ``_create_payload_from_song_data`` :param apikey: The Shazam API key. :returns: ``requests.Response`` containing the request response. The format for the API response can be found using the test functionality at the Shazam API website at https://rapidapi.com/apidojo/api/shazam. :raise requests.exceptions.RequestException: If the request fails due to network problems, too many redirections or other problems. - A detailed list of Exceptions ``requests``can raise can be found at (https://docs.python-requests.org/en/latest/user/quickstart/#errors-and-exceptions) + A detailed list of Exceptions ``requests`` can raise can be found at (https://docs.python-requests.org/en/latest/user/quickstart/#errors-and-exceptions) """ headers = { "content-type": "text/plain", "X-RapidAPI-Key": apikey, "X-RapidAPI-Host": "shazam.p.rapidapi.com", } - return requests.post(SHAZAM_URL_DETECT_V2, data=payload, headers=headers) + return requests.post( + SHAZAM_URL_DETECT_V2, data=payload, headers=headers, timeout=10 + ) + + +def _process_lookup_response(track): + """Extract the metadata from the "track" segtion of the response. + + The following metadata can potentially be retrieved: + * title + * artist + * album + * year + * isrc + * genre + + :param track: The "track" segment of the API response. + :returns: ``dict`` with the retrieved metadata. + """ + album = _extract_value_from_metadata(track, "Album") + year = _extract_value_from_metadata(track, "Released") + genre = ( + track["genres"]["primary"] + if ("genres" in track and "primary" in track["genres"]) + else None + ) + isrc = track["isrc"] if "isrc" in track else None + return { + "title": track["title"], + "artist": track["subtitle"], + "album": album, + "year": year, + "isrc": isrc, + "genre": genre, + } def _extract_value_from_metadata(track, key: str): diff --git a/src/backend/tests/test_acoustid_utils.py b/src/backend/tests/test_acoustid_utils.py new file mode 100644 index 0000000..941db9a --- /dev/null +++ b/src/backend/tests/test_acoustid_utils.py @@ -0,0 +1,1358 @@ +import acoustid +from modules.apis.acoustid import ( + _extract_recordings, + _filter_out_compilations_from_releasegroups, + _get_result_for_releasegroup, + _get_results_for_recordings, + _join_artist_names, + _merge_matching_recordings, + _parse_lookup_result, +) + +EXAMPLE_ACOUSTID_RESPONSE = { + "results": [ + { + "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + { + "id": "be6fa248-97d1-4a18-845b-7c6a070b764c", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + ], + "status": "ok", +} +"""Example response returned by the AcoustID API example request. +The metadata requested was changed to reflect the metadata the actual requests in the +acoustid module. +""" + +EXAMPLE_RECORDINGS = [ + { + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, + { + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, +] + + +EXAMPLE_ACOUSTID_RESPONSE_ONE_EMPTY = { + "results": [ + { + "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + { + "id": "be6fa248-97d1-4a18-845b-7c6a070b764c", + "score": 1.0, + }, + ], + "status": "ok", +} +"""Example response returned by the AcoustID API example request. +The metadata requested was changed to reflect the metadata the actual requests in the +acoustid module. +""" + + +EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_SONGS = { + "results": [ + { + "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "secondarytypes": ["Soundtrack"], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + { + "id": "3dfbb248-97d1-4a18-845b-7c6a070b764c", + "recordings": [ + { + "artists": [ + { + "id": "087b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "My Test Artist", + } + ], + "duration": 638, + "id": "dfbddc47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + ], + "status": "ok", +} +"""Example response returned by the AcoustID API example request. +The metadata requested was changed to reflect the metadata the actual requests in the +acoustid module. +""" + + +EXAMPLE_RECORDINGS_MULTIPLE_SONGS = [ + { + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "secondarytypes": ["Soundtrack"], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, + { + "artists": [ + { + "id": "087b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "My Test Artist", + } + ], + "duration": 638, + "id": "dfbddc47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, +] + +EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_ALBUMS = { + "results": [ + { + "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + { + "id": "be6fa248-97d1-4a18-845b-7c6a070b764c", + "recordings": [ + { + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + } + ], + "score": 1.0, + }, + ], + "status": "ok", +} +"""Example response returned by the AcoustID API example request. +The metadata requested was changed to reflect the metadata the actual requests in the +acoustid module. +""" + +EXAMPLE_RECORDINGS_MULTIPLE_ALBUMS = [ + { + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, + { + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ], + "title": "Lower Your Eyelids to Die With the Sun", + }, +] + +EXAMPLE_ACOUSTID_ERROR_RESPONSE = { + "error": {"code": 3, "message": "invalid fingerprint"}, + "status": "error", +} +"""An example for a response for a failed request.""" + +EXAMPLE_ACOUSTID_EMPTY_RESPONSE = {"status": "ok"} +"""An example for an empty response.""" + + +def test_parse_lookup_result_success(): + result = _parse_lookup_result(EXAMPLE_ACOUSTID_RESPONSE) + assert result == [ + { + "artist": "M83", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + ] + + +def test_parse_lookup_result_one_empty(): + result = _parse_lookup_result(EXAMPLE_ACOUSTID_RESPONSE_ONE_EMPTY) + assert result == [ + { + "artist": "M83", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + ] + + +def test_parse_lookup_result_multiple_albums(): + result = _parse_lookup_result(EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_ALBUMS) + assert result == [ + { + "artist": "M83", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + { + "artist": "M83", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "A Fake Album", + "albumartist": "M83", + }, + ] + + +def test_parse_lookup_result_multiple_songs(): + result = _parse_lookup_result(EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_SONGS) + assert result == [ + { + "artist": "M83", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + { + "artist": "My Test Artist", + "title": "Lower Your Eyelids to Die With the Sun", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + ] + + +def test_parse_lookup_result_error(): + try: + _parse_lookup_result(EXAMPLE_ACOUSTID_ERROR_RESPONSE) + assert 0 == 1 + except acoustid.WebServiceError: + # this error should be thrown. + assert 1 == 1 + + +def test_parse_lookup_result_empty(): + try: + _parse_lookup_result(EXAMPLE_ACOUSTID_EMPTY_RESPONSE) + assert 0 == 1 + except acoustid.WebServiceError: + # this error should be thrown. + assert 1 == 1 + + +# TODO: Test _extract_recordings +def test_extract_recordings(): + result = _extract_recordings(EXAMPLE_ACOUSTID_RESPONSE["results"]) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + } + ] + + +def test_extract_recordings_empty(): + result = _extract_recordings(EXAMPLE_ACOUSTID_RESPONSE_ONE_EMPTY["results"]) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + } + ] + + +def test_extract_recordings_multiple_albums(): + result = _extract_recordings(EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_ALBUMS["results"]) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + ], + } + ] + + +def test_extract_recordings_multiple_songs(): + result = _extract_recordings(EXAMPLE_ACOUSTID_RESPONSE_MULTIPLE_SONGS["results"]) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + }, + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [ + { + "id": "087b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "My Test Artist", + } + ], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + }, + ] + + +# TODO: test _merge_matching_recordings +def test_merge_matching_recordings(): + result = _merge_matching_recordings(EXAMPLE_RECORDINGS) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + } + ] + + +def test_merge_matching_recordings_empty(): + result = _merge_matching_recordings([]) + assert result == [] + + +def test_merge_matching_recordings_multiple_albums(): + result = _merge_matching_recordings(EXAMPLE_RECORDINGS_MULTIPLE_ALBUMS) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "A Fake Album", + "type": "Album", + }, + ], + } + ] + + +def test_merge_matching_recordings_multiple_songs(): + result = _merge_matching_recordings(EXAMPLE_RECORDINGS_MULTIPLE_SONGS) + assert result == [ + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + }, + { + "title": "Lower Your Eyelids to Die With the Sun", + "artists": [ + { + "id": "087b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "My Test Artist", + } + ], + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + ], + }, + ] + + +def test_filter_out_compilations_from_releasegroups(): + result = _filter_out_compilations_from_releasegroups( + [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ] + ) + assert result == [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + } + ] + + +def test_filter_out_compilations_from_releasegroups_multiple_actual_albums(): + result = _filter_out_compilations_from_releasegroups( + [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ] + ) + assert result == [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + ] + + +def test_filter_out_compilations_from_releasegroups_no_actual_albums(): + input = [ + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "secondarytypes": ["Compilation"], + "title": "Caf\u00e9 del Mar, Volumen Veinte", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "9e585041-f2c1-3f0d-be40-40c845a3323f", + "secondarytypes": ["Soundtrack"], + "title": "Donkey Punch", + "type": "Album", + }, + ] + + result = _filter_out_compilations_from_releasegroups(input) + assert result == input + + +def test_filter_out_compilations_from_releasegroups_empty(): + result = _filter_out_compilations_from_releasegroups([]) + assert result == [] + + +def test_get_results_for_recordings_single_recording(): + result = _get_results_for_recordings( + [ + { + "title": "My Test Song", + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "Test Recording", + "type": "Album", + }, + ], + } + ] + ) + assert result == [ + { + "title": "My Test Song", + "artist": "M83", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + { + "title": "My Test Song", + "artist": "M83", + "album": "Test Recording", + "albumartist": "Various Artists", + }, + ] + + +def test_get_results_for_recordings_no_recordings(): + result = _get_results_for_recordings([]) + assert result == [] + + +def test_get_results_for_recordings_multiple_recordings(): + result = _get_results_for_recordings( + [ + { + "title": "My Test Song", + "artists": [ + {"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"} + ], + "duration": 638, + "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "M83", + } + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "title": "Before the Dawn Heals Us", + "type": "Album", + }, + { + "artists": [ + { + "id": "89ad4ac3-39f7-470e-963a-56509c546377", + "name": "Various Artists", + } + ], + "id": "425771a3-31a4-4dc2-9dee-f2993611b44b", + "title": "Test Recording", + "type": "Album", + }, + ], + }, + { + "title": "A Very Cool Test Song", + "artists": [ + {"id": "12347cd4-254b-4c25-83f6-dd20f98ceacd", "name": "It Is I"}, + { + "id": "12347cd4-254b-4c25-83f6-ee20f98ceacd", + "name": "It Is Not You", + }, + ], + "duration": 638, + "id": "462e7c47-16f5-46c6-a37c-a1eb7bf599ff", + "releasegroups": [ + { + "artists": [ + { + "id": "12357cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "Not You", + }, + { + "id": "12357ce4-254b-4c25-83f6-dd20f98ceacd", + "name": "Not Me Either", + }, + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "secondarytypes": ["Compilation"], + "title": "The Album Title", + "type": "Album", + }, + ], + }, + ] + ) + assert result == [ + { + "title": "My Test Song", + "artist": "M83", + "album": "Before the Dawn Heals Us", + "albumartist": "M83", + }, + { + "title": "My Test Song", + "artist": "M83", + "album": "Test Recording", + "albumartist": "Various Artists", + }, + { + "title": "A Very Cool Test Song", + "artist": "It Is I; It Is Not You", + "album": "The Album Title", + "albumartist": "Not You; Not Me Either", + }, + ] + + +# TODO: test _get_result_for_releasegroup +def test_get_result_for_releasegroup(): + result = _get_result_for_releasegroup( + { + "artists": [ + { + "id": "12357cd4-254b-4c25-83f6-dd20f98ceacd", + "name": "Not You", + }, + { + "id": "12357ce4-254b-4c25-83f6-dd20f98ceacd", + "name": "Not Me Either", + }, + ], + "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", + "secondarytypes": ["Compilation"], + "title": "The Album Title", + "type": "Album", + }, + "TestArtist", + "TestSong", + ) + assert result == { + "title": "TestSong", + "artist": "TestArtist", + "album": "The Album Title", + "albumartist": "Not You; Not Me Either", + } + + +def test_join_artist_names_single_artist(): + result = _join_artist_names([{"id": "3241332421", "name": "Singular Artist"}]) + assert result == "Singular Artist" + + +def test_join_artist_names_multiple_artists(): + result = _join_artist_names( + [ + {"id": "3241332421", "name": "One of Two"}, + {"id": "35d3498523981", "name": "The Other"}, + {"id": "35d3498523981", "name": "And More!"}, + ] + ) + assert result == "One of Two; The Other; And More!" diff --git a/src/backend/tests/test_list_helper.py b/src/backend/tests/test_list_helper.py new file mode 100644 index 0000000..980b086 --- /dev/null +++ b/src/backend/tests/test_list_helper.py @@ -0,0 +1,53 @@ +import utils.list_helper + + +def test_flatten_numbers(): + arr = [[1, 4, 7], [99, 2]] + assert utils.list_helper.flatten(arr) == [1, 4, 7, 99, 2] + + +def test_flatten_dicts(): + result = utils.list_helper.flatten( + [ + [{"test": "test2", "test2": "1234"}, {"test": "wah wah", "test2": "wah."}], + [{"test": "meep", "test2": "boom"}], + [{"test": "things", "test2": "a lot of them"}], + ] + ) + assert result == [ + {"test": "test2", "test2": "1234"}, + {"test": "wah wah", "test2": "wah."}, + {"test": "meep", "test2": "boom"}, + {"test": "things", "test2": "a lot of them"}, + ] + + +# TODO: test remove_duplicate_dicts +def test_remove_duplicate_dicts_no_duplicates(): + input = [ + {"test": "test2", "test2": "1234"}, + {"test": "wah wah", "test2": "wah."}, + {"test": "meep", "test2": "boom"}, + ] + result = utils.list_helper.remove_duplicate_dicts(input) + assert result == input + + +def test_remove_duplicate_dicts_several_duplicates(): + input = [ + {"test": "test2", "test2": "1234"}, + {"test": "wah wah", "test2": "wah."}, + {"test": "meep", "test2": "boom"}, + {"test": "test2", "test2": "1234"}, + {"test": "wah wah", "test2": "wah."}, + ] + result = utils.list_helper.remove_duplicate_dicts(input) + assert result == [ + {"test": "test2", "test2": "1234"}, + {"test": "wah wah", "test2": "wah."}, + {"test": "meep", "test2": "boom"}, + ] + + +def test_remove_duplicate_dicts_empty(): + assert utils.list_helper.remove_duplicate_dicts([]) == [] diff --git a/src/backend/utils/list_helper.py b/src/backend/utils/list_helper.py new file mode 100644 index 0000000..9042f71 --- /dev/null +++ b/src/backend/utils/list_helper.py @@ -0,0 +1,15 @@ +"""list_helper offers tools to work with lists, mostly using the ``itertools`` library.""" + +import itertools + + +def flatten(nested_list): + return list(itertools.chain.from_iterable(nested_list)) + + +def remove_duplicate_dicts(list_of_dicts): + results = [] + for element in list_of_dicts: + if element not in results: + results.append(element) + return results diff --git a/src/frontend/components/ProjectItem.vue b/src/frontend/components/ProjectItem.vue index e96a5e3..401418a 100644 --- a/src/frontend/components/ProjectItem.vue +++ b/src/frontend/components/ProjectItem.vue @@ -132,9 +132,11 @@ function addRegion(segments: ProjectFileSegment[]) { const contentEl = document.createElement('div') if (meta) { - contentEl.innerHTML = Object.entries(meta).map(([key, value]) => + contentEl.innerHTML = Object.entries(meta).filter(([key, _]) => + ['title', 'artist', 'album'].includes(key), + ).map(([key, value]) => `
`, ).join('\n') } @@ -335,132 +337,158 @@ function handleEdit(songIndex: number) { -- {{ t('song.title') }} - | - -- {{ t('song.artist') }} - | - -- {{ t('song.album') }} - | - -- {{ t('song.year') }} - | - -- {{ t('song.duration') }} - | - -- {{ t('button.edit') }} - | - -- {{ t('button.save') }} - | -||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
- {{ metadataOptions?.[metaIndex]?.title ?? 'unknown' }} - | - -- {{ metadataOptions?.[metaIndex]?.artist ?? 'unknown' }} - | - -- {{ metadataOptions?.[metaIndex]?.album ?? 'unknown' }} - | - -- {{ metadataOptions?.[metaIndex]?.year ?? 'unknown' }} - | - -- {{ useConvertSecToMin(duration ?? 0) }} - | - -
- |
-
-
-
+
|
+