Prevent that library listings include child/parent mappings that got included from streaming providers
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
album_types: list[AlbumType] | None = None,
+ **kwargs: Any,
) -> list[Album]:
"""Get in-database albums.
:param extra_query: Additional SQL query string.
:param extra_query_params: Additional query parameters.
:param album_types: Filter by album types.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
"""
extra_query_params = extra_query_params or {}
extra_query_parts: list[str] = [extra_query] if extra_query else []
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
extra_join_parts=extra_join_parts,
+ in_library_only=library_items_only,
)
# Calculate how many more items we need to reach the original limit
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
extra_join_parts=extra_join_parts,
+ in_library_only=library_items_only,
):
# prevent duplicates (when artist is also in the title)
if album.uri not in existing_uris:
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
album_artists_only: bool = False,
+ **kwargs: Any,
) -> list[Artist]:
"""Get in-database (album) artists.
:param extra_query: Additional SQL query string.
:param extra_query_params: Additional query parameters.
:param album_artists_only: Only return artists that have albums.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
"""
extra_query_params = extra_query_params or {}
extra_query_parts: list[str] = [extra_query] if extra_query else []
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
async def tracks(
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
+ **kwargs: Any,
) -> list[Audiobook]:
"""Get in-database audiobooks.
:param provider: Filter by provider instance ID (single string or list).
:param extra_query: Additional SQL query string.
:param extra_query_params: Additional query parameters.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
"""
extra_query_params = extra_query_params or {}
extra_query_parts: list[str] = [extra_query] if extra_query else []
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
if search and len(result) < 25 and not offset:
# append author items to result
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
return result
# update existing item
await self._update_library_item(library_id, item, overwrite=overwrite_existing)
else:
- for provider_mapping in item.provider_mappings:
- if item.item_id == provider_mapping.item_id:
- provider_mapping.in_library = True
# actually add a new item in the library db
self.mass.music.match_provider_instances(item)
async with self._db_add_lock:
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
+ **kwargs: Any,
) -> list[ItemCls]:
- """Get in-database items."""
+ """
+ Get in-database albums.
+
+ :param favorite: Filter by favorite status.
+ :param search: Filter by search query.
+ :param limit: Maximum number of items to return.
+ :param offset: Number of items to skip.
+ :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
+ :param provider: Filter by provider instance ID (single string or list).
+ :param extra_query: Additional SQL query string.
+ :param extra_query_params: Additional query parameters.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
+ """
return await self._get_library_items_by_query(
favorite=favorite,
search=search,
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=[extra_query] if extra_query else None,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
async def iter_library_items(
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
) -> AsyncGenerator[ItemCls, None]:
"""Iterate all in-database items."""
limit: int = 500
provider_filter=provider_filter,
extra_query_parts=[extra_query] if extra_query else None,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
for item in next_items:
yield item
db_id = int(item_id) # ensure integer
extra_query = f"WHERE {self.db_table}.item_id = {item_id}"
for db_item in await self._get_library_items_by_query(
- extra_query_parts=[extra_query],
+ extra_query_parts=[extra_query], in_library_only=False
):
return db_item
msg = f"{self.media_type.value} not found in library: {db_id}"
offset=offset,
extra_query_parts=[query],
extra_query_params=query_params,
+ in_library_only=False,
)
@final
extra_query_parts: list[str] | None = None,
extra_query_params: dict[str, Any] | None = None,
extra_join_parts: list[str] | None = None,
+ in_library_only: bool = False,
) -> list[ItemCls]:
"""Fetch MediaItem records from database by building the query."""
query_params = extra_query_params or {}
favorite=favorite,
search=search,
provider_filter=provider_filter,
+ in_library_only=in_library_only,
limit=limit,
)
else:
favorite=favorite,
search=search,
provider_filter=provider_filter,
+ in_library_only=in_library_only,
)
# build and execute final query
sql_query = self._build_final_query(query_parts, join_parts, order_by)
favorite: bool | None,
search: str | None,
provider_filter: list[str] | None,
+ in_library_only: bool,
limit: int,
) -> None:
"""Build a fast random subquery with all filters applied."""
favorite=favorite,
search=search,
provider_filter=provider_filter,
+ in_library_only=in_library_only,
)
# Build the subquery
favorite: bool | None,
search: str | None,
provider_filter: list[str] | None,
+ in_library_only: bool,
) -> None:
"""Apply search, favorite, and provider filters."""
# handle search
join_parts.append(
f"JOIN provider_mappings ON provider_mappings.item_id = {self.db_table}.item_id "
f"AND provider_mappings.media_type = '{self.media_type.value}' "
- f"AND provider_mappings.in_library = 1 "
+ f"AND provider_mappings.in_library = {in_library_only} "
f"AND ({' OR '.join(provider_conditions)})"
)
+ elif in_library_only:
+ join_parts.append(
+ f"JOIN provider_mappings ON provider_mappings.item_id = {self.db_table}.item_id "
+ f"AND provider_mappings.media_type = '{self.media_type.value}' "
+ f"AND provider_mappings.in_library = {in_library_only} "
+ )
@final
def _build_final_query(
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
+ **kwargs: Any,
) -> list[Podcast]:
"""Get in-database podcasts.
:param provider: Filter by provider instance ID (single string or list).
:param extra_query: Additional SQL query string.
:param extra_query_params: Additional query parameters.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
"""
extra_query_params = extra_query_params or {}
extra_query_parts: list[str] = [extra_query] if extra_query else []
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
if search and len(result) < 25 and not offset:
# append publisher items to result
provider_filter=self._ensure_provider_filter(provider),
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
+ in_library_only=library_items_only,
)
return result
provider: str | list[str] | None = None,
extra_query: str | None = None,
extra_query_params: dict[str, Any] | None = None,
+ library_items_only: bool = True,
+ **kwargs: Any,
) -> list[Track]:
"""Get in-database tracks.
:param provider: Filter by provider instance ID (single string or list).
:param extra_query: Additional SQL query string.
:param extra_query_params: Additional query parameters.
+ :param library_items_only: If True, only return items that are
+ marked as 'in_library' on any provider mapping.
"""
extra_query_params = extra_query_params or {}
extra_query_parts: list[str] = [extra_query] if extra_query else []
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
extra_join_parts=extra_join_parts,
+ in_library_only=library_items_only,
)
if search and len(result) < 25 and not offset:
# append artist items to result
extra_query_parts=extra_query_parts,
extra_query_params=extra_query_params,
extra_join_parts=extra_join_parts,
+ in_library_only=library_items_only,
):
# prevent duplicates (when artist is also in the title)
if _track.uri not in existing_uris:
f"WHERE {DB_TABLE_ALBUM_TRACKS}.track_id = {item_id}"
)
query = f"{DB_TABLE_ALBUMS}.item_id in ({subquery})"
- return await self.mass.music.albums._get_library_items_by_query(extra_query_parts=[query])
+ return await self.mass.music.albums._get_library_items_by_query(
+ extra_query_parts=[query], in_library_only=True
+ )
async def match_provider(
self,
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] = 25
+DB_SCHEMA_VERSION: Final[int] = 26
CACHE_CATEGORY_LAST_SYNC: Final[int] = 9
CACHE_CATEGORY_SEARCH_RESULTS: Final[int] = 10
if "duplicate column" not in str(err):
raise
- if prev_version <= 25:
- # set in_library=True for local(file)-based providers
- # these providers always represent the user's actual library
+ if prev_version <= 26:
+ # force in_library=True for provider mappings from non-streaming providers
+ # streaming providers will be automatically added to library when synced
await self._database.execute(
f"UPDATE {DB_TABLE_PROVIDER_MAPPINGS} SET in_library = 1 "
- "WHERE provider_domain IN "
- "('filesystem_local', 'filesystem_smb', 'plex', "
- "'jellyfin', 'opensubsonic', 'builtin');"
+ "WHERE provider_domain NOT IN "
+ "('spotify', 'deezer', 'tidal', 'qobuz', 'apple_music', 'ytmusic');"
+ )
+ await self._database.execute(
+ f"UPDATE {DB_TABLE_PROVIDER_MAPPINGS} SET in_library = 1 "
+ "WHERE media_type IN "
+ "('radio', 'playlist');"
)
# save changes
self.audiobooks,
self.podcasts,
):
- async for db_item in ctrl.iter_library_items(provider=list(multi_instance_providers)):
+ async for db_item in ctrl.iter_library_items(
+ provider=list(multi_instance_providers), library_items_only=False
+ ):
if self.match_provider_instances(db_item):
await ctrl.update_item_in_library(db_item.item_id, db_item)
# prevent overwhelming the event loop
try:
library_item = await controller.get_library_item(db_id)
except MediaNotFoundError:
- # edge case: the item is already removed
+ # edge case: the item is (already) removed from MA library as well
continue
# check if we have other provider-mappings (marked as in-library)
- remaining_providers = {
+ remaining_providers_in_library = {
x.provider_instance
for x in library_item.provider_mappings
if x.provider_instance != self.instance_id and x.in_library
}
- if remaining_providers:
- # if we have other remaining providers, update the provider mappings
- for prov_map in library_item.provider_mappings:
- if prov_map.provider_instance == self.instance_id:
- prov_map.in_library = False
- await controller.set_provider_mappings(
- db_id, library_item.provider_mappings
- )
- else:
- # this item is removed from the provider's library
- # and we have no other providers attached to it
- # it is safe to remove it from the MA library too
- try:
- await controller.remove_item_from_library(
- db_id, recursive=media_type == MediaType.ALBUM
- )
- except MusicAssistantError as err:
- # this is probably because the item still has dependents
- self.logger.warning(
- "Error removing item %s from library: %s", db_id, str(err)
- )
- # just un-favorite the item if we can't remove it
- if library_item.favorite:
- await controller.set_favorite(db_id, False)
- for prov_map in library_item.provider_mappings:
- if prov_map.provider_instance == self.instance_id:
- prov_map.in_library = False
- await controller.set_provider_mappings(
- db_id, library_item.provider_mappings
- )
+ if not remaining_providers_in_library and library_item.favorite:
+ # unmark as favorite since no providers have it in library anymore
+ await controller.set_favorite(db_id, False)
+ # unmark this provider mapping as in_library = False
+ # we keep it in the library database so we can keep the metadata for future use
+ for prov_map in library_item.provider_mappings:
+ if prov_map.provider_instance == self.instance_id:
+ prov_map.in_library = False
+ await controller.set_provider_mappings(db_id, library_item.provider_mappings)
await asyncio.sleep(0) # yield to eventloop
# store current list of id's in cache so we can track changes
await self.mass.cache.set(
if not library_item:
# add item to the library
library_item = await self.mass.music.artists.add_item_to_library(prov_item)
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.artists.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.artists.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.artists.set_favorite(library_item.item_id, True)
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
except MusicAssistantError as err:
if not library_item:
# add item to the library
library_item = await self.mass.music.albums.add_item_to_library(prov_item)
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.albums.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.albums.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.albums.set_favorite(library_item.item_id, True)
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
# optionally add album tracks to library
if not library_item:
# add item to the library
library_item = await self.mass.music.audiobooks.add_item_to_library(prov_item)
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.audiobooks.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)
# check if resume_position_ms or fully_played changed
if (
if not library_item:
# add item to the library
library_item = await self.mass.music.playlists.add_item_to_library(prov_item)
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.playlists.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.playlists.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.playlists.set_favorite(library_item.item_id, True)
+
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
# optionally sync playlist tracks
if not library_item:
# add item to the library
library_item = await self.mass.music.tracks.add_item_to_library(prov_item)
- elif library_item.available != prov_item.available:
- # existing library item but availability changed
- library_item = await self.mass.music.tracks.update_item_in_library(
- library_item.item_id, prov_item
- )
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.tracks.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.tracks.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.tracks.set_favorite(library_item.item_id, True)
+
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
except MusicAssistantError as err:
if not library_item:
# add item to the library
library_item = await self.mass.music.podcasts.add_item_to_library(prov_item)
- elif library_item.available != prov_item.available:
- # existing library item but availability changed
- library_item = await self.mass.music.podcasts.update_item_in_library(
- library_item.item_id, prov_item
- )
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.podcasts.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
if not library_item:
# add item to the library
library_item = await self.mass.music.radio.add_item_to_library(prov_item)
- elif not library_item.favorite and prov_item.favorite:
- # existing library item not favorite but should be
- await self.mass.music.radio.set_favorite(library_item.item_id, True)
elif not self._check_provider_mappings(library_item, prov_item, True):
# existing library item but provider mapping doesn't match
library_item = await self.mass.music.radio.update_item_in_library(
library_item.item_id, prov_item
)
+ if not library_item.favorite and prov_item.favorite:
+ # existing library item not favorite but should be
+ await self.mass.music.radio.set_favorite(library_item.item_id, True)
cur_db_ids.add(int(library_item.item_id))
await asyncio.sleep(0) # yield to eventloop
artist_path = foldermatch
else:
# check if we have an existing item to retrieve the artist path
- async for item in self.mass.music.artists.iter_library_items(search=name):
+ async for item in self.mass.music.artists.iter_library_items(
+ search=name, provider=self.instance_id
+ ):
if not compare_strings(name, item.name):
continue
for prov_mapping in item.provider_mappings: