Fixes for multiple instances of the same provider (#2765)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 7 Dec 2025 23:44:40 +0000 (00:44 +0100)
committerGitHub <noreply@github.com>
Sun, 7 Dec 2025 23:44:40 +0000 (00:44 +0100)
17 files changed:
music_assistant/controllers/media/base.py
music_assistant/controllers/music.py
music_assistant/models/music_provider.py
music_assistant/providers/apple_music/__init__.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/sendspin/provider.py
music_assistant/providers/spotify/parsers.py
music_assistant/providers/spotify/provider.py
music_assistant/providers/tidal/parsers.py
music_assistant/providers/ytmusic/__init__.py
pyproject.toml
requirements_all.txt
tests/providers/jellyfin/__snapshots__/test_parsers.ambr
tests/providers/nicovideo/__snapshots__/test_converters.ambr
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr
tests/providers/tidal/__snapshots__/test_parsers.ambr

index e19902e12827ba34696895018cb7b91c31ddeca7..6c07919a2d53486a5ad60b494db3ea8973afb911 100644 (file)
@@ -7,7 +7,7 @@ import logging
 from abc import ABCMeta, abstractmethod
 from collections.abc import Iterable
 from contextlib import suppress
-from typing import TYPE_CHECKING, Any, TypeVar, cast
+from typing import TYPE_CHECKING, Any, TypeVar, cast, final
 
 from music_assistant_models.enums import EventType, ExternalID, MediaType, ProviderFeature
 from music_assistant_models.errors import (
@@ -116,6 +116,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         )
         self._db_add_lock = asyncio.Lock()
 
+    @final
     async def add_item_to_library(
         self,
         item: ItemCls,
@@ -129,6 +130,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             await self._update_library_item(library_id, item, overwrite=overwrite_existing)
         else:
             # actually add a new item in the library db
+            self.mass.music.match_provider_instances(item)
             async with self._db_add_lock:
                 library_id = await self._add_library_item(item)
                 new_item = True
@@ -141,6 +143,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         )
         return library_item
 
+    @final
     async def _get_library_item_by_match(self, item: ItemCls | ItemMapping) -> int | None:
         if item.provider == "library":
             return int(item.item_id)
@@ -169,10 +172,12 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 return int(db_item.item_id)
         return None
 
+    @final
     async def update_item_in_library(
         self, item_id: str | int, update: ItemCls, overwrite: bool = False
     ) -> ItemCls:
         """Update existing library record in the library database."""
+        self.mass.music.match_provider_instances(update)
         await self._update_library_item(item_id, update, overwrite=overwrite)
         # return the updated object
         library_item = await self.get_library_item(item_id)
@@ -376,6 +381,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             return item
         return None
 
+    @final
     async def get_library_item_by_prov_mappings(
         self,
         provider_mappings: Iterable[ProviderMapping],
@@ -397,6 +403,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 return item
         return None
 
+    @final
     async def get_library_item_by_external_id(
         self, external_id: str, external_id_type: ExternalID | None = None
     ) -> ItemCls | None:
@@ -413,6 +420,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             return item
         return None
 
+    @final
     async def get_library_item_by_external_ids(
         self, external_ids: set[tuple[ExternalID, str]]
     ) -> ItemCls | None:
@@ -422,6 +430,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 return match
         return None
 
+    @final
     async def get_library_items_by_prov_id(
         self,
         provider_domain: str | None = None,
@@ -461,6 +470,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             extra_query_params=query_params,
         )
 
+    @final
     async def iter_library_items_by_prov_id(
         self,
         provider_instance_id_or_domain: str,
@@ -482,6 +492,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 break
             offset += limit
 
+    @final
     async def set_favorite(self, item_id: str | int, favorite: bool) -> None:
         """Set the favorite bool on a database item."""
         db_id = int(item_id)  # ensure integer
@@ -494,6 +505,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item)
 
     @guard_single_request  # type: ignore[type-var]  # TODO: fix typing for MediaControllerBase
+    @final
     async def get_provider_item(
         self,
         item_id: str,
@@ -551,6 +563,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         )
         raise MediaNotFoundError(msg)
 
+    @final
     async def add_provider_mapping(
         self, item_id: str | int, provider_mapping: ProviderMapping
     ) -> None:
@@ -563,6 +576,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         library_item.provider_mappings.add(provider_mapping)
         await self.set_provider_mappings(db_id, library_item.provider_mappings)
 
+    @final
     async def remove_provider_mapping(
         self, item_id: str | int, provider_instance_id: str, provider_item_id: str
     ) -> None:
@@ -610,6 +624,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             with suppress(AssertionError):
                 await self.remove_item_from_library(db_id)
 
+    @final
     async def remove_provider_mappings(self, item_id: str | int, provider_instance_id: str) -> None:
         """Remove all provider mappings from an item."""
         db_id = int(item_id)  # ensure integer
@@ -645,6 +660,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             with suppress(AssertionError):
                 await self.remove_item_from_library(db_id)
 
+    @final
     async def set_provider_mappings(
         self,
         item_id: str | int,
@@ -708,6 +724,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
     ) -> list[Track]:
         """Get the list of base tracks from the controller used to calculate the dynamic radio."""
 
+    @final
     async def _get_library_items_by_query(
         self,
         favorite: bool | None = None,
@@ -755,6 +772,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             )
         ]
 
+    @final
     def _preprocess_search(self, search: str | None, query_params: dict[str, Any]) -> str | None:
         """Preprocess search string and add to query params."""
         if search:
@@ -762,11 +780,13 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             query_params["search"] = f"%{search}%"
         return search
 
+    @final
     @staticmethod
     def _clean_query_parts(query_parts: list[str]) -> list[str]:
         """Clean the query parts list by removing duplicate where statements."""
         return [x[5:] if x.lower().startswith("where ") else x for x in query_parts]
 
+    @final
     def _apply_random_subquery(
         self,
         query_parts: list[str],
@@ -808,6 +828,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         query_parts.append(f"{self.db_table}.item_id in ({sub_query})")
         join_parts.clear()
 
+    @final
     def _apply_filters(
         self,
         query_parts: list[str],
@@ -840,6 +861,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 f"AND ({' OR '.join(provider_conditions)})"
             )
 
+    @final
     def _build_final_query(
         self,
         query_parts: list[str],
@@ -867,6 +889,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
 
         return sql_query
 
+    @final
     @staticmethod
     def _parse_db_row(db_row: Mapping[str, Any]) -> dict[str, Any]:
         """Parse raw db Mapping into a dict."""
@@ -903,6 +926,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                     db_row_dict["metadata"]["images"] = [album_thumb]
         return db_row_dict
 
+    @final
     def _ensure_provider_filter(
         self,
         provider: str | list[str] | None,
@@ -934,6 +958,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
             final_provider_filter = [provider] if isinstance(provider, str) else provider
         return final_provider_filter
 
+    @final
     def _select_provider_id(self, library_item: ItemCls) -> tuple[str, str]:
         """Select the correct provider id to use for fetching the item."""
         user = get_current_user()
index fcb8bf9a9cb1bff140f69be04ec77e7e30123ea1..a26430e585a5842a26b769d2e5f422ff69595945 100644 (file)
@@ -35,6 +35,7 @@ from music_assistant_models.media_items import (
     BrowseFolder,
     ItemMapping,
     MediaItemType,
+    ProviderMapping,
     RecommendationFolder,
     SearchResults,
     Track,
@@ -94,7 +95,7 @@ CONF_RESET_DB = "reset_db"
 DEFAULT_SYNC_INTERVAL = 12 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
-DB_SCHEMA_VERSION: Final[int] = 22
+DB_SCHEMA_VERSION: Final[int] = 23
 
 CACHE_CATEGORY_LAST_SYNC: Final[int] = 9
 CACHE_CATEGORY_SEARCH_RESULTS: Final[int] = 10
@@ -1352,6 +1353,16 @@ class MusicController(CoreController):
             return self.genres
         raise NotImplementedError
 
+    def get_provider_instances(
+        self, domain: str, return_unavailable: bool = False
+    ) -> list[MusicProvider]:
+        """Return all provider instances for a given domain."""
+        return [
+            prov
+            for prov in self.providers
+            if prov.domain == domain and (return_unavailable or prov.available)
+        ]
+
     def get_unique_providers(self) -> set[str]:
         """
         Return all unique MusicProvider (instance or domain) ids.
@@ -1488,6 +1499,43 @@ class MusicController(CoreController):
             if sync_task.provider_instance == provider_instance_id:
                 sync_task.task.cancel()
 
+    def match_provider_instances(
+        self,
+        item: MediaItemType,
+    ) -> None:
+        """Match all provider instances for the given item."""
+        for provider_mapping in list(item.provider_mappings):
+            if provider_mapping.is_unique:
+                # unique mapping, no need to map
+                continue
+            if not (provider := self.mass.get_provider(item.provider)):
+                continue
+            if not provider.is_streaming_provider:
+                continue
+            provider_instances = self.get_provider_instances(
+                provider.domain, return_unavailable=True
+            )
+            if len(provider_instances) <= 1:
+                # only a single instance, no need to map
+                continue
+            for prov_instance in provider_instances:
+                if prov_instance.instance_id == provider.instance_id:
+                    continue
+                # create additional mapping for other provider instances of the same provider
+                item.provider_mappings.add(
+                    ProviderMapping(
+                        item_id=provider_mapping.item_id,
+                        provider_domain=provider.domain,
+                        provider_instance=prov_instance.instance_id,
+                        available=provider_mapping.available,
+                        is_unique=provider_mapping.is_unique,
+                        audio_format=provider_mapping.audio_format,
+                        url=provider_mapping.url,
+                        details=provider_mapping.details,
+                        in_library=provider_mapping.in_library,
+                    )
+                )
+
     async def _get_default_recommendations(self) -> list[RecommendationFolder]:
         """Return default recommendations."""
         return [
@@ -1824,7 +1872,7 @@ class MusicController(CoreController):
         else:
             self.logger.debug("Compacting database done")
 
-    async def __migrate_database(self, prev_version: int) -> None:
+    async def __migrate_database(self, prev_version: int) -> None:  # noqa: PLR0915
         """Perform a database migration."""
         self.logger.info(
             "Migrating database from version %s to %s", prev_version, DB_SCHEMA_VERSION
@@ -1956,6 +2004,16 @@ class MusicController(CoreController):
                 # If we can't create the index due to duplicate entries, log and continue
                 self.logger.warning("Could not create unique index on playlog: %s", err)
 
+        if prev_version <= 23:
+            # add is_unique column to provider_mappings table
+            try:
+                await self._database.execute(
+                    f"ALTER TABLE {DB_TABLE_PROVIDER_MAPPINGS} ADD COLUMN is_unique BOOLEAN"
+                )
+            except Exception as err:
+                if "duplicate column" not in str(err):
+                    raise
+
         # save changes
         await self._database.commit()
 
@@ -2150,6 +2208,7 @@ class MusicController(CoreController):
             [provider_item_id] TEXT NOT NULL,
             [available] BOOLEAN NOT NULL DEFAULT 1,
             [in_library] BOOLEAN NOT NULL DEFAULT 0,
+            [is_unique] BOOLEAN,
             [url] text,
             [audio_format] json,
             [details] TEXT,
index 4c241517f37c0302ddfb19d46eea9f3105c7fe1d..fdc455066c9af12b849f823f2d3d430ce1f07cc5 100644 (file)
@@ -1131,9 +1131,12 @@ class MusicProvider(Provider):
         """Check if provider mapping(s) are consistent between library and provider items."""
         for provider_mapping in provider_item.provider_mappings:
             if provider_mapping.item_id != provider_item.item_id:
+                # this should never happen, but guard against it
                 raise MusicAssistantError("Inconsistent provider mapping item_id found")
             if provider_mapping.provider_instance != self.instance_id:
+                # this should never happen, but guard against it
                 raise MusicAssistantError("Inconsistent provider mapping instance_id found")
+            # check if the provider mapping matches the library item
             provider_mapping.in_library = in_library
             library_mapping = next(
                 (
@@ -1147,6 +1150,34 @@ class MusicProvider(Provider):
             if not library_mapping:
                 return False
             if provider_mapping.in_library != library_mapping.in_library:
+                # in-library status doesn't match
                 return False
+            if provider_mapping.is_unique != library_mapping.is_unique:
+                # unique status doesn't match
+                return False
+            # check if the library item has all provider instances mappings
+            is_unique = provider_mapping.is_unique or (not self.is_streaming_provider)
+            if not is_unique:
+                # for streaming providers we need to make sure all provider instances
+                # for this domain are represented in the provider mappings
+                prov_instances = self.mass.music.get_provider_instances(
+                    domain=provider_mapping.provider_domain,
+                    return_unavailable=True,
+                )
+                if len(prov_instances) > 1:
+                    # multiple provider instances for this domain exist
+                    # make sure the library item has all provider mappings
+                    for prov_instance in prov_instances:
+                        if not any(
+                            x.provider_instance == prov_instance.instance_id
+                            and x.item_id == provider_mapping.item_id
+                            for x in library_item.provider_mappings
+                        ):
+                            # missing provider mapping for another instance
+                            # the rest of the core logic will take care of adding it
+                            # just return False here to trigger that logic
+                            return False
+
+            # final check: availability
             return provider_mapping.available == library_mapping.available
         return False
index f319221839bb65a479cdbd725d02ce4a23e6ebb1..ac352a930661830c9037cd27220e5bc5e864af00 100644 (file)
@@ -922,6 +922,7 @@ class AppleMusicProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=attributes.get("url"),
+                    is_unique=is_editable,  # user-owned playlists are unique
                 )
             },
             is_editable=is_editable,
index a8cd37f4855a9c5316f752526f1260875e9435ad..f3831cf1ddd8c38a6d70c088697d9c59275d1d5c 100644 (file)
@@ -657,6 +657,7 @@ class DeezerProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=getattr(playlist, "link", None),
+                    is_unique=is_editable,  # user-owned playlists are unique
                 )
             },
             metadata=MediaItemMetadata(
index c701f82c2c5af000702d4cb225c436c1c380a126..8644cf3c8f4210bef14cfaf3569649731d21fa2c 100644 (file)
@@ -737,6 +737,7 @@ class QobuzProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=f"https://open.qobuz.com/playlist/{playlist_obj['id']}",
+                    is_unique=is_editable,  # user-owned playlists are unique
                 )
             },
             is_editable=is_editable,
index cf6b4bc9a8d065485bef28b6448eccc29a612805..2f1ae4c59cbae662a5215f21dbdbdf345a308d57 100644 (file)
@@ -315,6 +315,7 @@ class SendspinProvider(PlayerProvider):
                 await self.mass.webserver.auth.update_user_filters(
                     user, player_filter=new_filter, provider_filter=None
                 )
+                user.player_filter = new_filter
 
         return {
             "local_ws_url": f"ws://{self.mass.streams.publish_ip}:8927/sendspin",
index 626813d9edbc8b78612411d051d166e6c9421a1a..9aac57caa245eb266cd4ed0aba17b31fb3a29221 100644 (file)
@@ -215,6 +215,7 @@ def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> P
                 provider_domain=provider.domain,
                 provider_instance=provider.instance_id,
                 url=playlist_obj["external_urls"]["spotify"],
+                is_unique=is_editable,  # user-owned playlists are unique
             )
         },
         is_editable=is_editable,
index 631f49351fc3b7bef25b88120cedde5620a68e75..c2d0db5249dba2ee451494802f3aeef4af63cdf3 100644 (file)
@@ -854,6 +854,7 @@ class SpotifyProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url="https://open.spotify.com/collection/tracks",
+                    is_unique=True,  # liked songs is user-specific
                 )
             },
         )
index 142fb6c4cba0d132bda5c2eef700934725f92a52..5911ae83b1ab84b7cb61db630c522e35b9353edb 100644 (file)
@@ -272,6 +272,7 @@ def parse_playlist(
                 provider_domain=provider.domain,
                 provider_instance=provider.instance_id,
                 url=f"{BROWSE_URL}/{url_path}/{raw_id}",
+                is_unique=is_editable,  # user-owned playlists are unique
             )
         },
         is_editable=is_editable,
index ab68bc17ba1276414f02edb8359e8fe85ccefa36..9cc5981af73dc9da7747faffda629cd75de1a099 100644 (file)
@@ -845,6 +845,7 @@ class YoutubeMusicProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=f"{YTM_DOMAIN}/playlist?list={playlist_id}",
+                    is_unique=is_editable,  # user-owned playlists are unique
                 )
             },
             is_editable=is_editable,
index 46527658d1e418058d3b693681d11b7cab627005..d60e8c20d96463b2a76d27bf5a821766efb9dbf9 100644 (file)
@@ -27,7 +27,7 @@ dependencies = [
   "get-mac==0.9.2",
   "mashumaro==3.17",
   "music-assistant-frontend==2.17.32",
-  "music-assistant-models==1.1.79",
+  "music-assistant-models==1.1.81",
   "mutagen==1.47.0",
   "orjson==3.11.4",
   "pillow==11.3.0",
index 582aa3fb56826d3bde4b6ba4baef24b2df52186c..5c2e2a4fbfa6f196b758db352509775c1da0d2bb 100644 (file)
@@ -40,7 +40,7 @@ librosa==0.11.0
 lyricsgenius==3.7.2
 mashumaro==3.17
 music-assistant-frontend==2.17.32
-music-assistant-models==1.1.79
+music-assistant-models==1.1.81
 mutagen==1.47.0
 niconico.py-ma==2.1.0.post1
 orjson==3.11.4
index 795059bab1c058405a0368aabb2da4aaf3cec2c3..200f9e56a8c81257e4bde6d8938f78695b5c395a 100644 (file)
@@ -79,6 +79,7 @@
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '70b7288088b42d318f75dbcc41fd0091',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '32ed6a0091733dcff57eae67010f3d4b',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'dd954bbf54398e247d803186d3585b79',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'da9c458e425584680765ddc3a89cbc0c',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'b5319fb11cde39fca2023184fcfa9862',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
index 27e0fa24adf591a92b21d3396042c44de4a9d643..1fa43e2a19feabb03435e534aea58279556c36fc 100644 (file)
@@ -49,6 +49,7 @@
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '527007',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
               'available': True,
               'details': None,
               'in_library': None,
+              'is_unique': None,
               'item_id': '68461151',
               'provider_domain': 'nicovideo',
               'provider_instance': 'nicovideo_test',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': '527007',
           'provider_domain': 'nicovideo',
           'provider_instance': 'nicovideo_test',
                 'available': True,
                 'details': None,
                 'in_library': None,
+                'is_unique': None,
                 'item_id': '68461151',
                 'provider_domain': 'nicovideo',
                 'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'sm45285955',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '527007',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '4',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '68461151',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '78597499',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '78597499',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': '78597499',
           'provider_domain': 'nicovideo',
           'provider_instance': 'nicovideo_test',
                 'available': True,
                 'details': None,
                 'in_library': None,
+                'is_unique': None,
                 'item_id': '68461151',
                 'provider_domain': 'nicovideo',
                 'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'sm45285955',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '78597499',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '527007',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
               'available': True,
               'details': None,
               'in_library': None,
+              'is_unique': None,
               'item_id': '68461151',
               'provider_domain': 'nicovideo',
               'provider_instance': 'nicovideo_test',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': '527007',
           'provider_domain': 'nicovideo',
           'provider_instance': 'nicovideo_test',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '68461151',
             'provider_domain': 'nicovideo',
             'provider_instance': 'nicovideo_test',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'sm45285955',
         'provider_domain': 'nicovideo',
         'provider_instance': 'nicovideo_test',
index 248f2bf84c1962b6c828dcc663881df59cb8f80b..d712db7e7afbe4fe3e71c54a00db444c1af2cbf4 100644 (file)
@@ -49,6 +49,7 @@
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '100000002',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '100000002',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           'available': True,
           'details': None,
           'in_library': None,
+          'is_unique': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'pd-5',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': 'pd-5',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'MA-NAVIDROME-The New Deal',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': 'MA-NAVIDROME-The New Deal',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
index 6a5b10795c01e49e8d7bcc2090d2ca16e04116a6..3499256490f77fd44053fc358cc6b80d3312fa79 100644 (file)
@@ -56,6 +56,7 @@
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '12345',
             'provider_domain': 'tidal',
             'provider_instance': 'tidal_instance',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '67890',
         'provider_domain': 'tidal',
         'provider_instance': 'tidal_instance',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '12345',
         'provider_domain': 'tidal',
         'provider_instance': 'tidal_instance',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': False,
         'item_id': 'mix_mix_123',
         'provider_domain': 'tidal',
         'provider_instance': 'tidal_instance',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': False,
         'item_id': 'aabbcc-1122-3344-5566',
         'provider_domain': 'tidal',
         'provider_instance': 'tidal_instance',
             'available': True,
             'details': None,
             'in_library': None,
+            'is_unique': None,
             'item_id': '12345',
             'provider_domain': 'tidal',
             'provider_instance': 'tidal_instance',
         'available': True,
         'details': None,
         'in_library': None,
+        'is_unique': None,
         'item_id': '112233',
         'provider_domain': 'tidal',
         'provider_instance': 'tidal_instance',