Cleanup Matching logic + add tests (#1472)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 8 Jul 2024 13:12:36 +0000 (15:12 +0200)
committerGitHub <noreply@github.com>
Mon, 8 Jul 2024 13:12:36 +0000 (15:12 +0200)
22 files changed:
.vscode/launch.json
music_assistant/common/models/media_items.py
music_assistant/constants.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/music.py
music_assistant/server/helpers/compare.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/apple_music/__init__.py
music_assistant/server/providers/builtin/__init__.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
tests/server/providers/jellyfin/__snapshots__/test_parsers.ambr
tests/server/test_compare.py [new file with mode: 0644]
tests/test_helpers.py

index 7ea91c4b43b9d50d8f0d0dbf440529bd145e1159..d665d454d750390e01949fbaf7ac906dfc06ab52 100644 (file)
@@ -1,19 +1,32 @@
 {
-    // Use IntelliSense to learn about possible attributes.
-    // Hover to view descriptions of existing attributes.
-    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "name": "Python: Module",
-            "type": "debugpy",
-            "request": "launch",
-            "module": "music_assistant",
-            "justMyCode": false,
-            "args":[
-                 "--log-level", "debug"
-            ],
-            "env": {"PYTHONDEVMODE": "1"}
-        }
-    ]
+  // Use IntelliSense to learn about possible attributes.
+  // Hover to view descriptions of existing attributes.
+  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "Music Assistant: Server",
+      "type": "debugpy",
+      "request": "launch",
+      "module": "music_assistant",
+      "justMyCode": false,
+      "args": ["--log-level", "debug"],
+      "env": { "PYTHONDEVMODE": "1" }
+    },
+    {
+      "name": "Music Assistant: Tests",
+      "type": "debugpy",
+      "request": "launch",
+      "module": "pytest",
+      "justMyCode": false,
+      "args": ["tests"]
+    },
+    {
+      "name": "Python Debugger: Current File",
+      "type": "debugpy",
+      "request": "launch",
+      "program": "${file}",
+      "console": "integratedTerminal"
+    }
+  ]
 }
index 3ec3adaaee204823125f787bee64f7c7cc200ce4..d44b39f853ac765cfb05dc0789346855a43a84f8 100644 (file)
@@ -233,8 +233,6 @@ class MediaItemMetadata(DataClassDictMixin):
     performers: set[str] | None = None
     preview: str | None = None
     popularity: int | None = None
-    # cache_checksum: optional value to (in)validate cache / detect changes (used for playlists)
-    cache_checksum: str | None = None
     # last_refresh: timestamp the (full) metadata was last collected
     last_refresh: int | None = None
 
@@ -478,6 +476,10 @@ class Playlist(MediaItem):
     owner: str = ""
     is_editable: bool = False
 
+    # cache_checksum: optional value to (in)validate cache
+    # detect changes to the playlist tracks listing
+    cache_checksum: str | None = None
+
 
 @dataclass(kw_only=True)
 class Radio(MediaItem):
index c7a789da92de6a174fa0106eb94a4e104dac16d0..c949429e658efe8ed7fe08a8a7bce14b602fc8fb 100644 (file)
@@ -5,7 +5,7 @@ from typing import Final
 
 API_SCHEMA_VERSION: Final[int] = 24
 MIN_SCHEMA_VERSION: Final[int] = 24
-DB_SCHEMA_VERSION: Final[int] = 2
+
 
 MASS_LOGGER_NAME: Final[str] = "music_assistant"
 
index 7e9028ad0004c1ee65eac131ff6c6f7027bbd749..870f33742ac522846e773609e4d56b8156c31f12 100644 (file)
@@ -14,12 +14,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
 from music_assistant.common.helpers.json import json_dumps, json_loads
 from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant.common.models.enums import ConfigEntryType
-from music_assistant.constants import (
-    DB_SCHEMA_VERSION,
-    DB_TABLE_CACHE,
-    DB_TABLE_SETTINGS,
-    MASS_LOGGER_NAME,
-)
+from music_assistant.constants import DB_TABLE_CACHE, DB_TABLE_SETTINGS, MASS_LOGGER_NAME
 from music_assistant.server.helpers.database import DatabaseConnection
 from music_assistant.server.models.core_controller import CoreController
 
@@ -28,6 +23,7 @@ if TYPE_CHECKING:
 
 LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.cache")
 CONF_CLEAR_CACHE = "clear_cache"
+DB_SCHEMA_VERSION = 1
 
 
 class CacheController(CoreController):
index 95c4c3bb7cf89a23a38452fb04a886aa12e295d6..7af8570efac0120c7fe368f78845fb575984fcb6 100644 (file)
@@ -35,6 +35,7 @@ from music_assistant.server.controllers.media.base import MediaControllerBase
 from music_assistant.server.helpers.compare import (
     compare_album,
     compare_artists,
+    compare_media_item,
     loose_compare_strings,
 )
 
@@ -119,11 +120,6 @@ class AlbumsController(MediaControllerBase[Album]):
                     prov_track = await self.mass.music.tracks.get_provider_item(
                         track_prov_map.item_id, track_prov_map.provider_instance, force_refresh=True
                     )
-                    if (
-                        prov_track.metadata.cache_checksum
-                        == prov_album_track.metadata.cache_checksum
-                    ):
-                        continue
                     await self.mass.music.tracks._update_library_item(
                         prov_album_track.item_id, prov_track, True
                     )
@@ -442,9 +438,9 @@ class AlbumsController(MediaControllerBase[Album]):
                 for search_result_item in search_result:
                     if not search_result_item.available:
                         continue
-                    if not compare_album(db_album, search_result_item):
+                    if not compare_media_item(db_album, search_result_item):
                         continue
-                    # we must fetch the full album version, search results are simplified objects
+                    # we must fetch the full album version, search results can be simplified objects
                     prov_album = await self.get_provider_item(
                         search_result_item.item_id,
                         search_result_item.provider,
index ef205a569fdbdc4081f507c020f0564601f44b67..65c0ba786be2701f6e8814eee138e57283ac6834 100644 (file)
@@ -59,7 +59,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
             lazy=not force_refresh,
         )
         prov_map = next(x for x in playlist.provider_mappings)
-        cache_checksum = playlist.metadata.cache_checksum
+        cache_checksum = playlist.cache_checksum
         tracks = await self._get_provider_playlist_tracks(
             prov_map.item_id,
             prov_map.provider_instance,
index 3737cbb1352169e7f821fb627ad96e4bcb9ff911..60596f08b63ec30d290b68a54601191d42839acd 100644 (file)
@@ -25,6 +25,7 @@ from music_assistant.constants import (
 )
 from music_assistant.server.helpers.compare import (
     compare_artists,
+    compare_media_item,
     compare_track,
     loose_compare_strings,
 )
@@ -268,9 +269,9 @@ class TracksController(MediaControllerBase[Track]):
                     if not search_result_item.available:
                         continue
                     # do a basic compare first
-                    if not compare_track(db_track, search_result_item, strict=False):
+                    if not compare_media_item(db_track, search_result_item, strict=False):
                         continue
-                    # we must fetch the full version, search results are simplified objects
+                    # we must fetch the full version, search results can be simplified objects
                     prov_track = await self.get_provider_item(
                         search_result_item.item_id,
                         search_result_item.provider,
index 4640c78e732440de9734c1993026ebbd05278693..e4a7b9fc2bb8b2e459c655a99d2b2361a7a091e9 100644 (file)
@@ -9,7 +9,7 @@ import shutil
 from contextlib import suppress
 from itertools import zip_longest
 from math import inf
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
 
 from music_assistant.common.helpers.datetime import utc_timestamp
 from music_assistant.common.helpers.global_cache import get_global_cache_value
@@ -33,7 +33,6 @@ from music_assistant.common.models.media_items import BrowseFolder, MediaItemTyp
 from music_assistant.common.models.provider import SyncTask
 from music_assistant.common.models.streamdetails import LoudnessMeasurement
 from music_assistant.constants import (
-    DB_SCHEMA_VERSION,
     DB_TABLE_ALBUM_ARTISTS,
     DB_TABLE_ALBUM_TRACKS,
     DB_TABLE_ALBUMS,
@@ -66,6 +65,7 @@ DEFAULT_SYNC_INTERVAL = 3 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
 CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
+DB_SCHEMA_VERSION: Final[int] = 2
 
 
 class MusicController(CoreController):
index 89bf7121af4198fd8e77dbc41391df8641b58248..768a6cae5e722999ace3ad026306c0c9ca09c33e 100644 (file)
@@ -21,11 +21,10 @@ from music_assistant.common.models.media_items import (
 )
 
 IGNORE_VERSIONS = (
-    "remaster",
-    "explicit",
+    "explicit",  # explicit is matched separately
     "music from and inspired by the motion picture",
     "original soundtrack",
-    "hi-res",
+    "hi-res",  # quality is handled separately
 )
 
 
@@ -107,18 +106,21 @@ def compare_album(
     # for strict matching we REQUIRE both items to be a real album object
     assert isinstance(base_item, Album)
     assert isinstance(compare_item, Album)
+    # compare year
+    if base_item.year and compare_item.year and base_item.year != compare_item.year:
+        return False
     # compare explicitness
     if compare_explicit(base_item.metadata, compare_item.metadata) is False:
         return False
-    # compare album artist
-    return compare_artists(base_item.artists, compare_item.artists, True)
+    # compare album artist(s)
+    return compare_artists(base_item.artists, compare_item.artists, not strict)
 
 
 def compare_track(
-    base_item: Track | ItemMapping,
-    compare_item: Track | ItemMapping,
+    base_item: Track,
+    compare_item: Track,
     strict: bool = True,
-    track_albums: list[Album | ItemMapping] | None = None,
+    track_albums: list[Album] | None = None,
 ) -> bool:
     """Compare two track items and return True if they match."""
     if base_item is None or compare_item is None:
@@ -142,7 +144,22 @@ def compare_track(
         )
         if external_id_match is not None:
             return external_id_match
+    # return early on exact albumtrack match = 100% match
+    if (
+        base_item.album
+        and compare_item.album
+        and compare_album(base_item.album, compare_item.album, False)
+        and base_item.disc_number
+        and compare_item.disc_number
+        and base_item.track_number
+        and compare_item.track_number
+        and base_item.disc_number == compare_item.disc_number
+        and base_item.track_number == compare_item.track_number
+    ):
+        return True
+
     ## fallback to comparing on attributes
+
     # compare name
     if not compare_strings(base_item.name, compare_item.name, strict=True):
         return False
@@ -159,26 +176,17 @@ def compare_track(
         compare_item.metadata.explicit = compare_item.album.metadata.explicit
     if strict and compare_explicit(base_item.metadata, compare_item.metadata) is False:
         return False
-    if not strict and not (base_item.album or track_albums):
-        # in non-strict mode, the album does not have to match (but duration needs to)
-        return abs(base_item.duration - compare_item.duration) <= 2
-    # exact albumtrack match = 100% match
-    if (
-        base_item.album
-        and compare_item.album
-        and compare_album(base_item.album, compare_item.album, False)
-        and base_item.disc_number == compare_item.disc_number
-        and base_item.track_number == compare_item.track_number
-    ):
-        return True
+
     # fallback: exact album match and (near-exact) track duration match
     if (
         base_item.album is not None
         and compare_item.album is not None
+        and (base_item.track_number == 0 or compare_item.track_number == 0)
         and compare_album(base_item.album, compare_item.album, False)
         and abs(base_item.duration - compare_item.duration) <= 3
     ):
         return True
+
     # fallback: additional compare albums provided for base track
     if (
         compare_item.album is not None
@@ -188,13 +196,28 @@ def compare_track(
         for track_album in track_albums:
             if compare_album(track_album, compare_item.album, False):
                 return True
-    # accept last resort: albumless track and (near) exact duration
-    # otherwise fail all other cases
-    return (
+
+    # fallback edge case: albumless track with same duration
+    if (
         base_item.album is None
         and compare_item.album is None
-        and abs(base_item.duration - compare_item.duration) <= 1
-    )
+        and base_item.disc_number == 0
+        and compare_item.disc_number == 0
+        and base_item.track_number == 0
+        and compare_item.track_number == 0
+        and base_item.duration == compare_item.duration
+    ):
+        return True
+
+    if strict:
+        # in strict mode, we require an exact album match so return False here
+        return False
+
+    # Accept last resort (in non strict mode): (near) exact duration,
+    # otherwise fail all other cases.
+    # Note that as this stage, all other info already matches,
+    # such as title artist etc.
+    return abs(base_item.duration - compare_item.duration) <= 2
 
 
 def compare_playlist(
@@ -265,6 +288,14 @@ def compare_artists(
     any_match: bool = True,
 ) -> bool:
     """Compare two lists of artist and return True if both lists match (exactly)."""
+    if not base_items and not compare_items:
+        return True
+    if not base_items or not compare_items:
+        return False
+    # match if first artist matches in both lists
+    if compare_artist(base_items[0], compare_items[0]):
+        return True
+    # compare the artist lists
     matches = 0
     for base_item in base_items:
         for compare_item in compare_items:
@@ -272,7 +303,7 @@ def compare_artists(
                 if any_match:
                     return True
                 matches += 1
-    return len(base_items) == matches
+    return len(base_items) == len(compare_items) == matches
 
 
 def compare_albums(
@@ -399,7 +430,7 @@ def compare_strings(str1: str, str2: str, strict: bool = True) -> bool:
     if create_safe_string(str1) == create_safe_string(str2):
         return True
     # last resort: use difflib to compare strings
-    required_accuracy = 0.91 if len(str1) > 8 else 0.85
+    required_accuracy = 0.9 if (len(str1) + len(str2)) > 18 else 0.8
     return SequenceMatcher(a=str1_lower, b=str2).ratio() > required_accuracy
 
 
@@ -415,11 +446,18 @@ def compare_version(base_version: str, compare_version: str) -> bool:
         return False
     if base_version and not compare_version:
         return False
-    if " " not in base_version:
-        return compare_strings(base_version, compare_version)
+
+    if " " not in base_version and " " not in compare_version:
+        return compare_strings(base_version, compare_version, False)
+
     # do this the hard way as sometimes the version string is in the wrong order
-    base_versions = base_version.lower().split(" ").sort()
-    compare_versions = compare_version.lower().split(" ").sort()
+    base_versions = sorted(base_version.lower().split(" "))
+    compare_versions = sorted(compare_version.lower().split(" "))
+    # filter out words we can ignore (such as 'version')
+    ignore_words = [*IGNORE_VERSIONS, "version", "edition", "variant", "versie", "versione"]
+    base_versions = [x for x in base_versions if x not in ignore_words]
+    compare_versions = [x for x in compare_versions if x not in ignore_words]
+
     return base_versions == compare_versions
 
 
index 8c6403b8d895ef5a2d57bfa1a20d125891927249..6ec8a2f225832da889c24a3ca29e7eda08ae8b92 100644 (file)
@@ -419,8 +419,10 @@ class MusicProvider(Provider):
                         library_item = await controller.add_item_to_library(
                             prov_item, metadata_lookup=False
                         )
-                    elif library_item.metadata.cache_checksum != prov_item.metadata.cache_checksum:
-                        # existing dbitem checksum changed
+                    elif getattr(library_item, "cache_checksum", None) != getattr(
+                        prov_item, "cache_checksum", None
+                    ):
+                        # existing dbitem checksum changed (playlists only)
                         library_item = await controller.update_item_in_library(
                             library_item.item_id, prov_item
                         )
index a2eb4cdde494ac199fb72559386e2ead50c85450..481ae9b6a3c66939381ba2ba2af8b4ed16ee2ee3 100644 (file)
@@ -596,7 +596,7 @@ class AppleMusicProvider(MusicProvider):
             playlist.metadata.description = description.get("standard")
         playlist.is_editable = attributes.get("canEdit", False)
         if checksum := attributes.get("lastModifiedDate"):
-            playlist.metadata.cache_checksum = checksum
+            playlist.cache_checksum = checksum
         return playlist
 
     async def _get_all_items(self, endpoint, key="data", **kwargs) -> list[dict]:
index f3b55e3f09fa4348d14310c81a670688b9376181..cde2e4a9612a64e8cb988e849f80dd99c54e158b 100644 (file)
@@ -39,7 +39,7 @@ from music_assistant.common.models.media_items import (
     UniqueList,
 )
 from music_assistant.common.models.streamdetails import StreamDetails
-from music_assistant.constants import DB_SCHEMA_VERSION, MASS_LOGO, VARIOUS_ARTISTS_FANART
+from music_assistant.constants import MASS_LOGO, VARIOUS_ARTISTS_FANART
 from music_assistant.server.helpers.tags import AudioTags, parse_tags
 from music_assistant.server.models.music_provider import MusicProvider
 
@@ -237,11 +237,11 @@ class BuiltinProvider(MusicProvider):
                 },
                 owner="Music Assistant",
                 is_editable=False,
+                cache_checksum=str(int(time.time())),
                 metadata=MediaItemMetadata(
                     images=UniqueList([DEFAULT_THUMB])
                     if prov_playlist_id in COLLAGE_IMAGE_PLAYLISTS
                     else UniqueList([DEFAULT_THUMB, DEFAULT_FANART]),
-                    cache_checksum=str(int(time.time())),
                 ),
             )
         # user created universal playlist
@@ -263,7 +263,7 @@ class BuiltinProvider(MusicProvider):
             owner="Music Assistant",
             is_editable=True,
         )
-        playlist.metadata.cache_checksum = f"{DB_SCHEMA_VERSION}.{stored_item.get('last_updated')}"
+        playlist.cache_checksum = str(stored_item.get("last_updated"))
         if image_url := stored_item.get("image_url"):
             playlist.metadata.images = UniqueList(
                 [
index 461687b2d1d809073ec9e03d1261530d0375ebf0..168e955b2ed101fd562a89535501f7956fbebc5f 100644 (file)
@@ -626,10 +626,10 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                         remotely_accessible=True,
                     )
                 ],
-                cache_checksum=playlist.checksum,
             ),
             is_editable=creator.id == self.user.id,
             owner=creator.name,
+            cache_checksum=playlist.checksum,
         )
 
     def get_playlist_creator(self, playlist: deezer.Playlist):
index a81b04412cd4f3510b6938a7578d51eeab6c3131..2da274f7c8fb5b0e1ed73a9a4ff2e1ea99cb8886 100644 (file)
@@ -49,7 +49,6 @@ from music_assistant.constants import (
     DB_TABLE_TRACK_ARTISTS,
     VARIOUS_ARTISTS_NAME,
 )
-from music_assistant.server.controllers.music import DB_SCHEMA_VERSION
 from music_assistant.server.helpers.compare import compare_strings
 from music_assistant.server.helpers.playlists import parse_m3u, parse_pls
 from music_assistant.server.helpers.tags import parse_tags, split_items
@@ -332,7 +331,8 @@ class FileSystemProviderBase(MusicProvider):
             cur_filenames.add(item.path)
             try:
                 # continue if the item did not change (checksum still the same)
-                if item.checksum == file_checksums.get(item.path):
+                prev_checksum = file_checksums.get(item.path)
+                if item.checksum == prev_checksum:
                     continue
                 self.logger.debug("Processing: %s", item.path)
                 if item.ext in TRACK_EXTENSIONS:
@@ -341,16 +341,18 @@ class FileSystemProviderBase(MusicProvider):
                     # when they are detected as changed
                     track = await self._parse_track(item)
                     await self.mass.music.tracks.add_item_to_library(
-                        track, metadata_lookup=False, overwrite_existing=True
+                        track, metadata_lookup=False, overwrite_existing=prev_checksum is not None
                     )
                 elif item.ext in PLAYLIST_EXTENSIONS:
                     playlist = await self.get_playlist(item.path)
                     # add/update] playlist to db
-                    playlist.metadata.cache_checksum = item.checksum
+                    playlist.cache_checksum = item.checksum
                     # playlist is always favorite
                     playlist.favorite = True
                     await self.mass.music.playlists.add_item_to_library(
-                        playlist, metadata_lookup=False, overwrite_existing=True
+                        playlist,
+                        metadata_lookup=False,
+                        overwrite_existing=prev_checksum is not None,
                     )
             except Exception as err:  # pylint: disable=broad-except
                 # we don't want the whole sync to crash on one file so we catch all exceptions here
@@ -503,8 +505,8 @@ class FileSystemProviderBase(MusicProvider):
         if file_item.ext == "pls":
             playlist.is_editable = False
         playlist.owner = self.name
-        checksum = f"{DB_SCHEMA_VERSION}.{file_item.checksum}"
-        playlist.metadata.cache_checksum = checksum
+        checksum = str(file_item.checksum)
+        playlist.cache_checksum = checksum
         return playlist
 
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
@@ -735,7 +737,7 @@ class FileSystemProviderBase(MusicProvider):
         if acoustid := tags.get("acoustidid"):
             track.external_ids.add((ExternalID.ACOUSTID, acoustid))
 
-        album: Album | None
+        album: Album | None = None
         album_artists: list[Artist] = []
 
         # album
@@ -801,9 +803,7 @@ class FileSystemProviderBase(MusicProvider):
         # track artist(s)
         for index, track_artist_str in enumerate(tags.artists):
             # reuse album artist details if possible
-            if album and (
-                album_artist := next((x for x in album_artists if x.name == track_artist_str), None)
-            ):
+            if album_artist := next((x for x in album_artists if x.name == track_artist_str), None):
                 artist = album_artist
             else:
                 artist = await self._parse_artist(track_artist_str)
@@ -860,14 +860,6 @@ class FileSystemProviderBase(MusicProvider):
                 album.year = tags.year
             album.album_type = tags.album_type
             album.metadata.explicit = track.metadata.explicit
-        # set checksum to invalidate any cached listings
-        track.metadata.cache_checksum = file_item.checksum
-        if album:
-            # use track checksum for album(artists) too
-            album.metadata.cache_checksum = track.metadata.cache_checksum
-            for artist in album_artists:
-                artist.metadata.cache_checksum = track.metadata.cache_checksum
-
         return track
 
     async def _parse_artist(
index a7483caa948a8cd2b9772412b7e9b391580f13e2..d82e9f57f47ad27391e3f18e53261da9e57fb004 100644 (file)
@@ -17,6 +17,7 @@ from plexapi.audio import Track as PlexTrack
 from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
 from plexapi.server import PlexServer
 
+from music_assistant.common.helpers.util import parse_title_and_version
 from music_assistant.common.models.config_entries import (
     ConfigEntry,
     ConfigValueOption,
@@ -180,7 +181,7 @@ async def get_config_entries(  # noqa: PLR0915
         ),
         ConfigEntry(
             key=CONF_LOCAL_SERVER_PORT,
-            type=ConfigEntryType.STRING,
+            type=ConfigEntryType.INTEGER,
             label="Local server port",
             description="The local server port (e.g. 32400)",
             required=True,
@@ -402,11 +403,17 @@ class PlexProvider(MusicProvider):
         return await self._run_async(self._plex_library.fetchItem, key, cls)
 
     def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
+        name, version = parse_title_and_version(name)
+        if media_type in (MediaType.ALBUM, MediaType.TRACK):
+            name, version = parse_title_and_version(name)
+        else:
+            version = ""
         return ItemMapping(
             media_type=media_type,
             item_id=key,
             provider=self.instance_id,
             name=name,
+            version=version,
         )
 
     async def _get_or_create_artist_by_name(self, artist_name) -> Artist:
@@ -598,7 +605,7 @@ class PlexProvider(MusicProvider):
                 )
             ]
         playlist.is_editable = not plex_playlist.smart
-        playlist.metadata.cache_checksum = str(plex_playlist.updatedAt.timestamp())
+        playlist.cache_checksum = str(plex_playlist.updatedAt.timestamp())
 
         return playlist
 
@@ -668,7 +675,7 @@ class PlexProvider(MusicProvider):
             ]
         if plex_track.parentKey:
             track.album = self._get_item_mapping(
-                MediaType.ALBUM, plex_track.parentKey, plex_track.parentKey
+                MediaType.ALBUM, plex_track.parentKey, plex_track.parentTitle
             )
         if plex_track.duration:
             track.duration = int(plex_track.duration / 1000)
index d49e8f60d1e740862de9ba2b4909362e71bb4b71..6ec60a6f1642b888b196a6593ff7d2558f128378 100644 (file)
@@ -681,7 +681,7 @@ class QobuzProvider(MusicProvider):
                     remotely_accessible=True,
                 )
             ]
-        playlist.metadata.cache_checksum = str(playlist_obj["updated_at"])
+        playlist.cache_checksum = str(playlist_obj["updated_at"])
         return playlist
 
     async def _auth_token(self):
index d5caa674a349d0668a479735260d1f6879f086b3..0d8b61fe08208a63f5b8d33a775b90e3f850d435 100644 (file)
@@ -271,7 +271,7 @@ class SpotifyProvider(MusicProvider):
             )
         ]
 
-        liked_songs.metadata.cache_checksum = str(time.time())
+        liked_songs.cache_checksum = str(time.time())
 
         return liked_songs
 
@@ -622,7 +622,7 @@ class SpotifyProvider(MusicProvider):
             ]
         if playlist.owner is None:
             playlist.owner = self._sp_user["display_name"]
-        playlist.metadata.cache_checksum = str(playlist_obj["snapshot_id"])
+        playlist.cache_checksum = str(playlist_obj["snapshot_id"])
         return playlist
 
     async def login(self) -> dict:
index b39fa289f358879e85bcdc5c711993f794c1b191..6fab63ff0fac0c91f3c676b299cdd2c831f0ac94 100644 (file)
@@ -723,7 +723,7 @@ class TidalProvider(MusicProvider):
         is_editable = bool(creator_id and str(creator_id) == self._tidal_user_id)
         playlist.is_editable = is_editable
         # metadata
-        playlist.metadata.cache_checksum = str(playlist_obj.last_updated)
+        playlist.cache_checksum = str(playlist_obj.last_updated)
         playlist.metadata.popularity = playlist_obj.popularity
         if picture := (playlist_obj.square_picture or playlist_obj.picture):
             picture_id = picture.replace("-", "/")
index 8102185b47b38aa0f2283f09099d925489400ec3..48fbd3ccc013f9a9489dacbe12f5164773dcc903 100644 (file)
@@ -711,7 +711,7 @@ class YoutubeMusicProvider(MusicProvider):
                 playlist.owner = authors["name"]
         else:
             playlist.owner = self.instance_id
-        playlist.metadata.cache_checksum = playlist_obj.get("checksum")
+        playlist.cache_checksum = playlist_obj.get("checksum")
         return playlist
 
     def _parse_track(self, track_obj: dict) -> Track:
index 6de9723a21b9420f1df21a5e7f298dcddabfb823..79998f7b744d0c816647c36ebf7b8e8543ba4935 100644 (file)
@@ -27,7 +27,6 @@
     'item_id': '70b7288088b42d318f75dbcc41fd0091',
     'media_type': 'album',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': '32ed6a0091733dcff57eae67010f3d4b',
     'media_type': 'album',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
     'media_type': 'album',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': 'dd954bbf54398e247d803186d3585b79',
     'media_type': 'artist',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': 'b5319fb11cde39fca2023184fcfa9862',
     'media_type': 'track',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
     'media_type': 'track',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
     'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
     'media_type': 'track',
     'metadata': dict({
-      'cache_checksum': None,
       'chapters': None,
       'copyright': None,
       'description': None,
diff --git a/tests/server/test_compare.py b/tests/server/test_compare.py
new file mode 100644 (file)
index 0000000..9cfb020
--- /dev/null
@@ -0,0 +1,289 @@
+"""Tests for mediaitem compare helper functions."""
+
+from music_assistant.common.models import media_items
+from music_assistant.server.helpers import compare
+
+
+def test_compare_version() -> None:
+    """Test the version compare helper."""
+    assert compare.compare_version("Remaster", "remaster") is True
+    assert compare.compare_version("Remastered", "remaster") is True
+    assert compare.compare_version("Remaster", "") is False
+    assert compare.compare_version("Remaster", "Remix") is False
+    assert compare.compare_version("", "Deluxe") is False
+    assert compare.compare_version("", "Live") is False
+    assert compare.compare_version("Live", "live") is True
+    assert compare.compare_version("Live", "live version") is True
+    assert compare.compare_version("Live version", "live") is True
+    assert compare.compare_version("Deluxe Edition", "Deluxe") is True
+    assert compare.compare_version("Deluxe Karaoke Edition", "Deluxe") is False
+    assert compare.compare_version("Deluxe Karaoke Edition", "Karaoke") is False
+    assert compare.compare_version("Deluxe Edition", "Edition Deluxe") is True
+    assert compare.compare_version("", "Karaoke Version") is False
+    assert compare.compare_version("Karaoke", "Karaoke Version") is True
+    assert compare.compare_version("Remaster", "Remaster Edition Deluxe") is False
+    assert compare.compare_version("Remastered Version", "Deluxe Version") is False
+
+
+def test_compare_artist() -> None:
+    """Test artist comparison."""
+    artist_a = media_items.Artist(
+        item_id="1",
+        provider="test1",
+        name="Artist A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="1", provider_domain="test", provider_instance="test1"
+            )
+        },
+    )
+    artist_b = media_items.Artist(
+        item_id="1",
+        provider="test2",
+        name="Artist A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="2", provider_domain="test", provider_instance="test2"
+            )
+        },
+    )
+    # test match on name match
+    assert compare.compare_artist(artist_a, artist_b) is True
+    # test match on name mismatch
+    artist_b.name = "Artist B"
+    assert compare.compare_artist(artist_a, artist_b) is False
+    # test on exact item_id match
+    artist_b.item_id = artist_a.item_id
+    artist_b.provider = artist_a.provider
+    assert compare.compare_artist(artist_a, artist_b) is True
+    # test on external id match
+    artist_b.name = "Artist B"
+    artist_b.item_id = "2"
+    artist_b.provider = "test2"
+    artist_a.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "123")}
+    artist_b.external_ids = artist_a.external_ids
+    assert compare.compare_artist(artist_a, artist_b) is True
+    # test on external id mismatch
+    artist_b.name = artist_a.name
+    artist_b.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "1234")}
+    assert compare.compare_artist(artist_a, artist_b) is False
+
+
+def test_compare_album() -> None:
+    """Test album comparison."""
+    album_a = media_items.Album(
+        item_id="1",
+        provider="test1",
+        name="Album A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="1", provider_domain="test", provider_instance="test1"
+            )
+        },
+    )
+    album_b = media_items.Album(
+        item_id="1",
+        provider="test2",
+        name="Album A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="2", provider_domain="test", provider_instance="test2"
+            )
+        },
+    )
+    # test match on name match
+    assert compare.compare_album(album_a, album_b) is True
+    # test match on name mismatch
+    album_b.name = "Album B"
+    assert compare.compare_album(album_a, album_b) is False
+    # test on version mismatch
+    album_b.name = album_a.name
+    album_b.version = "Deluxe"
+    assert compare.compare_album(album_a, album_b) is False
+    album_b.version = "Remix"
+    assert compare.compare_album(album_a, album_b) is False
+    # test on version match
+    album_b.name = album_a.name
+    album_a.version = "Deluxe"
+    album_b.version = "Deluxe Edition"
+    assert compare.compare_album(album_a, album_b) is True
+    # test on exact item_id match
+    album_b.item_id = album_a.item_id
+    album_b.provider = album_a.provider
+    assert compare.compare_album(album_a, album_b) is True
+    # test on external id match
+    album_b.name = "Album B"
+    album_b.item_id = "2"
+    album_b.provider = "test2"
+    album_a.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "123")}
+    album_b.external_ids = album_a.external_ids
+    assert compare.compare_album(album_a, album_b) is True
+    # test on external id mismatch
+    album_b.name = album_a.name
+    album_b.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "1234")}
+    assert compare.compare_album(album_a, album_b) is False
+    album_a.external_ids = set()
+    album_b.external_ids = set()
+    # fail on year mismatch
+    album_b.external_ids = set()
+    album_a.year = 2021
+    album_b.year = 2020
+    assert compare.compare_album(album_a, album_b) is False
+    # pass on year match
+    album_b.year = 2021
+    assert compare.compare_album(album_a, album_b) is True
+    # fail on artist mismatch
+    album_a.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
+    )
+    album_b.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
+    )
+    assert compare.compare_album(album_a, album_b) is False
+    # pass on partial artist match (if first artist matches)
+    album_a.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
+    )
+    album_b.artists = media_items.UniqueList(
+        [
+            media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
+            media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
+        ]
+    )
+    assert compare.compare_album(album_a, album_b) is True
+    # fail on partial artist match in strict mode
+    album_b.artists = media_items.UniqueList(
+        [
+            media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
+            media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
+        ]
+    )
+    assert compare.compare_album(album_a, album_b) is False
+    # partial artist match is allowed in non-strict mode
+    assert compare.compare_album(album_a, album_b, False) is True
+
+
+def test_compare_track() -> None:  # noqa: PLR0915
+    """Test track comparison."""
+    track_a = media_items.Track(
+        item_id="1",
+        provider="test1",
+        name="Track A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="1", provider_domain="test", provider_instance="test1"
+            )
+        },
+    )
+    track_b = media_items.Track(
+        item_id="1",
+        provider="test2",
+        name="Track A",
+        provider_mappings={
+            media_items.ProviderMapping(
+                item_id="2", provider_domain="test", provider_instance="test2"
+            )
+        },
+    )
+    # test match on name match
+    assert compare.compare_track(track_a, track_b) is True
+    # test match on name mismatch
+    track_b.name = "Track B"
+    assert compare.compare_track(track_a, track_b) is False
+    # test on version mismatch
+    track_b.name = track_a.name
+    track_b.version = "Deluxe"
+    assert compare.compare_track(track_a, track_b) is False
+    track_b.version = "Remix"
+    assert compare.compare_track(track_a, track_b) is False
+    # test on version mismatch
+    track_b.name = track_a.name
+    track_a.version = ""
+    track_b.version = "Remaster"
+    assert compare.compare_track(track_a, track_b) is False
+    track_b.version = "Remix"
+    assert compare.compare_track(track_a, track_b) is False
+    # test on version match
+    track_b.name = track_a.name
+    track_a.version = "Deluxe"
+    track_b.version = "Deluxe Edition"
+    assert compare.compare_track(track_a, track_b) is True
+    # test on exact item_id match
+    track_b.item_id = track_a.item_id
+    track_b.provider = track_a.provider
+    assert compare.compare_track(track_a, track_b) is True
+    # test on external id match
+    track_b.name = "Track B"
+    track_b.item_id = "2"
+    track_b.provider = "test2"
+    track_a.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "123")}
+    track_b.external_ids = track_a.external_ids
+    assert compare.compare_track(track_a, track_b) is True
+    # test on external id mismatch
+    track_b.name = track_a.name
+    track_b.external_ids = {(media_items.ExternalID.MUSICBRAINZ, "1234")}
+    assert compare.compare_track(track_a, track_b) is False
+    track_a.external_ids = set()
+    track_b.external_ids = set()
+    # fail on artist mismatch
+    track_a.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
+    )
+    track_b.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="2", provider="test1", name="Artist B")]
+    )
+    assert compare.compare_track(track_a, track_b) is False
+    # pass on partial artist match (if first artist matches)
+    track_a.artists = media_items.UniqueList(
+        [media_items.ItemMapping(item_id="1", provider="test1", name="Artist A")]
+    )
+    track_b.artists = media_items.UniqueList(
+        [
+            media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
+            media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
+        ]
+    )
+    assert compare.compare_track(track_a, track_b) is True
+    # fail on partial artist match in strict mode
+    track_b.artists = media_items.UniqueList(
+        [
+            media_items.ItemMapping(item_id="2", provider="test1", name="Artist B"),
+            media_items.ItemMapping(item_id="1", provider="test1", name="Artist A"),
+        ]
+    )
+    assert compare.compare_track(track_a, track_b) is False
+    # partial artist match is allowed in non-strict mode
+    assert compare.compare_track(track_a, track_b, False) is True
+    track_b.artists = track_a.artists
+    # fail on album mismatch
+    track_a.album = media_items.ItemMapping(item_id="1", provider="test1", name="Album A")
+    track_b.album = media_items.ItemMapping(item_id="2", provider="test1", name="Album B")
+    assert compare.compare_track(track_a, track_b) is False
+    # pass on exact album(track) match (regardless duration)
+    track_b.album = track_a.album
+    track_a.disc_number = 1
+    track_a.track_number = 1
+    track_b.disc_number = track_a.disc_number
+    track_b.track_number = track_a.track_number
+    track_a.duration = 300
+    track_b.duration = 310
+    assert compare.compare_track(track_a, track_b) is True
+    # pass on album(track) mismatch
+    track_b.album = track_a.album
+    track_a.disc_number = 1
+    track_a.track_number = 1
+    track_b.disc_number = track_a.disc_number
+    track_b.track_number = 2
+    track_b.duration = track_a.duration
+    assert compare.compare_track(track_a, track_b) is False
+    # test special case - ISRC match but MusicBrainz ID mismatch
+    # this can happen for some classical music albums
+    track_a.external_ids = {
+        (media_items.ExternalID.ISRC, "123"),
+        (media_items.ExternalID.MUSICBRAINZ, "abc"),
+    }
+    track_b.external_ids = {
+        (media_items.ExternalID.ISRC, "123"),
+        (media_items.ExternalID.MUSICBRAINZ, "abcd"),
+    }
+    assert compare.compare_track(track_a, track_b) is False
index a3a194fabe54e6e290cba1e67e2bc2853d797d1a..5b87ffa47406dd2981432af4f5c8db82d05e7c2a 100644 (file)
@@ -10,10 +10,26 @@ from music_assistant.constants import SILENCE_FILE
 
 def test_version_extract() -> None:
     """Test the extraction of version from title."""
+    test_str = "Bam Bam (feat. Ed Sheeran)"
+    title, version = util.parse_title_and_version(test_str)
+    assert title == "Bam Bam"
+    assert version == ""
     test_str = "Bam Bam (feat. Ed Sheeran) - Karaoke Version"
     title, version = util.parse_title_and_version(test_str)
     assert title == "Bam Bam"
     assert version == "Karaoke Version"
+    test_str = "SuperSong (2011 Remaster)"
+    title, version = util.parse_title_and_version(test_str)
+    assert title == "SuperSong"
+    assert version == "Remaster"
+    test_str = "SuperSong (Live at Wembley)"
+    title, version = util.parse_title_and_version(test_str)
+    assert title == "SuperSong"
+    assert version == "Live At Wembley"
+    test_str = "SuperSong (Instrumental)"
+    title, version = util.parse_title_and_version(test_str)
+    assert title == "SuperSong"
+    assert version == "Instrumental"
 
 
 async def test_uri_parsing() -> None: