From 2ace3b490a81095c7ba65cea530cd29127344cfd Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:25:41 +0200 Subject: [PATCH 1/9] add series controller --- music_assistant/constants.py | 10 + music_assistant/controllers/config.py | 3 + music_assistant/controllers/media/base.py | 4 +- music_assistant/controllers/media/series.py | 316 ++++++++++++++++++++ music_assistant/controllers/music.py | 25 ++ music_assistant/helpers/compare.py | 33 ++ music_assistant/models/music_provider.py | 84 ++++++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 music_assistant/controllers/media/series.py diff --git a/music_assistant/constants.py b/music_assistant/constants.py index cc21b15442..afad1f153b 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -150,6 +150,7 @@ DB_TABLE_PLAYLISTS: Final[str] = "playlists" DB_TABLE_RADIOS: Final[str] = "radios" DB_TABLE_AUDIOBOOKS: Final[str] = "audiobooks" +DB_TABLE_SERIES: Final[str] = "series" DB_TABLE_PODCASTS: Final[str] = "podcasts" DB_TABLE_CACHE: Final[str] = "cache" DB_TABLE_SETTINGS: Final[str] = "settings" @@ -729,6 +730,15 @@ def create_output_codec_config_entry( default_value=True, category="sync_options", ) +CONF_ENTRY_LIBRARY_SYNC_SERIES = ConfigEntry( + key="library_sync_seriess", + type=ConfigEntryType.BOOLEAN, + label="Sync Library Series from this source to Music Assistant", + description="Whether to import (favourited/in-library) series from this " + "source to the Music Assistant Library.", + default_value=True, + category="sync_options", +) CONF_ENTRY_LIBRARY_SYNC_RADIOS = ConfigEntry( key="library_sync_radios", type=ConfigEntryType.BOOLEAN, diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 180c526808..36597f9e26 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -68,6 +68,7 @@ CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS, CONF_ENTRY_LIBRARY_SYNC_PODCASTS, CONF_ENTRY_LIBRARY_SYNC_RADIOS, + CONF_ENTRY_LIBRARY_SYNC_SERIES, CONF_ENTRY_LIBRARY_SYNC_TRACKS, CONF_ENTRY_OUTPUT_CHANNELS, CONF_ENTRY_OUTPUT_CODEC, @@ -423,6 +424,8 @@ async def get_provider_config_entries( extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS) if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features: extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS) + if ProviderFeature.LIBRARY_SERIES in supported_features: + extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_SERIES) if ProviderFeature.LIBRARY_PODCASTS in supported_features: extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PODCASTS) if ProviderFeature.LIBRARY_RADIOS in supported_features: diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index 69e5613d35..24b39f3a11 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -117,7 +117,9 @@ def __init__(self, mass: MusicAssistant) -> None: FROM {self.db_table} """ self.logger = logging.getLogger(f"{MASS_LOGGER_NAME}.music.{self.media_type.value}") # register (base) api handlers - self.api_base = api_base = f"{self.media_type}s" + self.api_base = api_base = ( + f"{self.media_type}s" if self.media_type != MediaType.SERIES else self.media_type + ) self.mass.register_api_command(f"music/{api_base}/count", self.library_count) self.mass.register_api_command(f"music/{api_base}/library_items", self.library_items) self.mass.register_api_command(f"music/{api_base}/get", self.get) diff --git a/music_assistant/controllers/media/series.py b/music_assistant/controllers/media/series.py new file mode 100644 index 0000000000..10ad807741 --- /dev/null +++ b/music_assistant/controllers/media/series.py @@ -0,0 +1,316 @@ +"""Manage MediaItems of type Series.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Sequence +from typing import TYPE_CHECKING, Any, cast + +from music_assistant_models.enums import MediaType, ProviderFeature +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import ( + Audiobook, + ProviderMapping, + Series, + UniqueList, +) + +from music_assistant.constants import DB_TABLE_SERIES +from music_assistant.controllers.media.base import MediaControllerBase +from music_assistant.helpers.compare import ( + compare_media_item, + compare_series, + create_safe_string, + loose_compare_strings, +) +from music_assistant.helpers.database import UNSET +from music_assistant.helpers.json import serialize_to_json +from music_assistant.helpers.util import guard_single_request +from music_assistant.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant_models.media_items import Track + + from music_assistant import MusicAssistant + + +class SeriesController(MediaControllerBase[Series]): + """Controller managing MediaItems of type Series.""" + + db_table = DB_TABLE_SERIES + media_type = MediaType.SERIES + item_cls = Series + + def __init__(self, mass: MusicAssistant) -> None: + """Initialize class.""" + super().__init__(mass) + # register (extra) api handlers + api_base = self.api_base + self.mass.register_api_command(f"music/{api_base}/audiobooks", self.audiobooks) + + async def library_items( + self, + favorite: bool | None = None, + search: str | None = None, + limit: int = 500, + offset: int = 0, + order_by: str = "sort_name", + provider: str | list[str] | None = None, + genre: int | list[int] | None = None, + **kwargs: Any, + ) -> list[Series]: + """Get in-database series. + + :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 genre: Filter by genre id(s). + """ + return await self.get_library_items_by_query( + favorite=favorite, + search=search, + genre_ids=genre, + limit=limit, + offset=offset, + order_by=order_by, + provider_filter=self._ensure_provider_filter(provider), + in_library_only=True, + ) + + async def audiobooks( + self, + item_id: str, + provider_instance_id_or_domain: str, + ) -> AsyncGenerator[Audiobook, None]: + """Return audiobooks for the given provider series id.""" + # always check if we have a library item for this series + if provider_instance_id_or_domain == "library": + library_series = await self.get_library_item(item_id) + if not library_series: + raise MediaNotFoundError(f"Series {item_id} not found in library") + provider_instance_id_or_domain, item_id = self._select_provider_id(library_series) + + # series audiobooks are not stored in the db, + page = 0 + while True: + audiobooks = await self._get_provider_series_audiobooks( + item_id, + provider_instance_id_or_domain, + page=page, + ) + if not audiobooks: + break + for audiobook in audiobooks: + yield audiobook + page += 1 + + @guard_single_request # type: ignore[type-var] # TODO: fix typing in util.py + async def _get_provider_series_audiobooks( + self, + item_id: str, + provider_instance_id_or_domain: str, + page: int = 0, + force_refresh: bool = False, + ) -> Sequence[Audiobook]: + """Return playlist tracks for the given provider playlist id.""" + assert provider_instance_id_or_domain != "library" + if not (provider := self.mass.get_provider(provider_instance_id_or_domain)): + return [] + provider = cast("MusicProvider", provider) + async with self.mass.cache.handle_refresh(force_refresh): + return await provider.get_series_audiobooks(item_id, page=page) + + async def versions( + self, + item_id: str, + provider_instance_id_or_domain: str, + ) -> UniqueList[Series]: + """Return all versions of an podcast we can find on all providers.""" + series = await self.get_provider_item(item_id, provider_instance_id_or_domain) + search_query = series.name + result: UniqueList[Series] = UniqueList() + for provider_id in self.mass.music.get_unique_providers(): + provider = self.mass.get_provider(provider_id) + if not isinstance(provider, MusicProvider): + continue + if not provider.library_supported(MediaType.SERIES): + continue + result.extend( + prov_item + for prov_item in await self.search(search_query, provider_id) + if loose_compare_strings(series.name, prov_item.name) + # make sure that the 'base' version is NOT included + and not series.provider_mappings.intersection(prov_item.provider_mappings) + ) + return result + + async def _add_library_item(self, item: Series, overwrite_existing: bool = False) -> int: + """Add a new record to the database.""" + db_id = await self.mass.music.database.insert( + self.db_table, + { + "name": item.name, + "sort_name": item.sort_name, + "version": item.version, + "favorite": item.favorite, + "metadata": serialize_to_json(item.metadata), + "external_ids": serialize_to_json(item.external_ids), + "search_name": create_safe_string(item.name, True, True), + "search_sort_name": create_safe_string(item.sort_name or "", True, True), + "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET, + "in_progress": item.in_progress, + "progress_percent": item.progress_percent, + }, + ) + # update/set provider_mappings table + await self.set_provider_mappings(db_id, item.provider_mappings) + self.logger.debug("added %s to database (id: %s)", item.name, db_id) + return db_id + + async def _update_library_item( + self, item_id: str | int, update: Series, overwrite: bool = False + ) -> None: + """Update existing record in the database.""" + db_id = int(item_id) # ensure integer + cur_item = await self.get_library_item(db_id) + metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata) + cur_item.external_ids.update(update.external_ids) + name = update.name if overwrite else cur_item.name + sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + "name": name, + "sort_name": sort_name, + "version": update.version if overwrite else cur_item.version or update.version, + "metadata": serialize_to_json(metadata), + "external_ids": serialize_to_json( + update.external_ids if overwrite else cur_item.external_ids + ), + "search_name": create_safe_string(name, True, True), + "search_sort_name": create_safe_string(sort_name or "", True, True), + "timestamp_added": int(update.date_added.timestamp()) + if update.date_added + else UNSET, + "in_progress": update.in_progress, + "progress_percent": update.progress_percent, + }, + ) + # update/set provider_mappings table + provider_mappings = ( + update.provider_mappings + if overwrite + else {*update.provider_mappings, *cur_item.provider_mappings} + ) + await self.set_provider_mappings(db_id, provider_mappings, overwrite) + self.logger.debug("updated %s in database: (id %s)", update.name, db_id) + + async def radio_mode_base_tracks( + self, + item: Series, + preferred_provider_instances: list[str] | None = None, + ) -> list[Track]: + """ + Get the list of base tracks from the controller used to calculate the dynamic radio. + + :param item: The Podcast to get base tracks for. + :param preferred_provider_instances: List of preferred provider instance IDs to use. + """ + msg = "Dynamic tracks not supported for Series MediaItem" + raise NotImplementedError(msg) + + async def match_provider( + self, db_series: Series, provider: MusicProvider, strict: bool = True + ) -> list[ProviderMapping]: + """ + Try to find match on (streaming) provider for the provided (database) series. + + This is used to link objects of different providers/qualities together. + """ + self.logger.debug( + "Trying to match series %s on provider %s", + db_series.name, + provider.name, + ) + matches: list[ProviderMapping] = [] + search_str = db_series.name + search_result = await self.search(search_str, provider.instance_id) + for search_result_item in search_result: + if not search_result_item.available: + continue + if not compare_media_item(db_series, search_result_item, strict=strict): + continue + # we must fetch the full podcast version, search results can be simplified objects + prov_podcast = await self.get_provider_item( + search_result_item.item_id, + search_result_item.provider, + fallback=search_result_item, + ) + if compare_series(db_series, prov_podcast, strict=strict): + # 100% match + matches.extend(prov_podcast.provider_mappings) + if not matches: + self.logger.debug( + "Could not find match for Podcast %s on provider %s", + db_series.name, + provider.name, + ) + return matches + + async def match_providers(self, db_item: Series) -> None: + """Try to find match on all (streaming) providers for the provided (database) series. + + This is used to link objects of different providers/qualities together. + """ + if db_item.provider != "library": + return # Matching only supported for database items + + # try to find match on all providers + cur_provider_domains = {x.provider_domain for x in db_item.provider_mappings} + for provider in self.mass.music.providers: + if provider.domain in cur_provider_domains: + continue + if ProviderFeature.SEARCH not in provider.supported_features: + continue + if not provider.library_supported(MediaType.SERIES): + continue + if not provider.is_streaming_provider: + # matching on unique providers is pointless as they push (all) their content to MA + continue + if match := await self.match_provider(db_item, provider): + # 100% match, we update the db with the additional provider mapping(s) + await self.add_provider_mappings(db_item.item_id, match) + cur_provider_domains.add(provider.domain) + + async def get( + self, item_id: str, provider_instance_id_or_domain: str, allow_update_metadata: bool = True + ) -> Series: + """Get series, and enhance with progress if available.""" + series = await super().get(item_id, provider_instance_id_or_domain, allow_update_metadata) + if series.in_progress is not None and series.progress_percent is not None: + return series + + total_books = 0 + total_books_finished = 0 + in_progress = False + async for book in self.audiobooks(item_id, provider_instance_id_or_domain): + total_books += 1 + if book.fully_played: + total_books_finished += 1 + elif ( + book.fully_played is not None + and not book.fully_played + and book.resume_position_ms is not None + and book.resume_position_ms != 0 + ): + in_progress = True + + if total_books == 0: + return series + + series.in_progress = in_progress if total_books != total_books_finished else False + series.progress_percent = int(total_books_finished / total_books * 100) + return series diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 08220aabb4..a3a9e34cbd 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -66,6 +66,7 @@ DB_TABLE_PODCASTS, DB_TABLE_PROVIDER_MAPPINGS, DB_TABLE_RADIOS, + DB_TABLE_SERIES, DB_TABLE_SETTINGS, DB_TABLE_SMART_FADES_ANALYSIS, DB_TABLE_TRACK_ARTISTS, @@ -73,6 +74,7 @@ DEFAULT_GENRE_MAPPING, PROVIDERS_WITH_SHAREABLE_URLS, ) +from music_assistant.controllers.media.series import SeriesController from music_assistant.controllers.streams.smart_fades.fades import SMART_CROSSFADE_DURATION from music_assistant.controllers.tasks.context import update_current_task_progress_text from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user @@ -139,6 +141,7 @@ def __init__(self, mass: MusicAssistant) -> None: self.audiobooks = AudiobooksController(self.mass) self.podcasts = PodcastsController(self.mass) self.genres = GenreController(self.mass) + self.series = SeriesController(self.mass) self._database: DatabaseConnection | None = None self._sync_lock = asyncio.Lock() self.manifest.name = "Music controller" @@ -1569,6 +1572,7 @@ def get_controller( | AudiobooksController | PodcastsController | GenreController + | SeriesController ): """Return controller for MediaType.""" if media_type == MediaType.ARTIST: @@ -1589,6 +1593,8 @@ def get_controller( return self.podcasts if media_type == MediaType.GENRE: return self.genres + if media_type == MediaType.SERIES: + return self.series raise NotImplementedError def get_provider_instances( @@ -3050,6 +3056,25 @@ async def __create_database_tables(self) -> None: UNIQUE(item_id,provider,aa_provider_domain,media_type));""" ) + await self.database.execute( + f""" + CREATE TABLE IF NOT EXISTS {DB_TABLE_SERIES}( + [item_id] INTEGER PRIMARY KEY AUTOINCREMENT, + [name] TEXT NOT NULL, + [sort_name] TEXT NOT NULL, + [version] TEXT, + [favorite] BOOLEAN NOT NULL DEFAULT 0, + [metadata] json NOT NULL, + [external_ids] json NOT NULL, + [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)), + [timestamp_modified] INTEGER NOT NULL DEFAULT 0, + [search_name] TEXT NOT NULL, + [search_sort_name] TEXT NOT NULL, + [in_progress] BOOLEAN DEFAULT NULL, + [progress_percent] INTEGER DEFAULT NULL + );""" + ) + await self.database.commit() async def __create_database_indexes(self) -> None: diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index a4a51ca04b..524f68e036 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -18,6 +18,7 @@ Playlist, Podcast, Radio, + Series, Track, ) @@ -373,6 +374,38 @@ def compare_podcast( ) +def compare_series( + base_item: Series | ItemMapping, + compare_item: Series | ItemMapping, + strict: bool = True, +) -> bool | None: + """Compare two Podcast items and return True if they match.""" + # return early on exact item_id match + if compare_item_ids(base_item, compare_item): + return True + + # return early on (un)matched external id + for ext_id in ( + ExternalID.ASIN, + ExternalID.BARCODE, + ): + external_id_match = compare_external_ids( + base_item.external_ids, compare_item.external_ids, ext_id + ) + if external_id_match is not None: + return external_id_match + + # compare version + if not compare_version(base_item.version, compare_item.version): + return False + # compare name + if not compare_strings(base_item.name, compare_item.name, strict=True): + return False + if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)): + return True + return compare_strings(base_item.name, compare_item.name, strict=True) + + def compare_item_mapping( base_item: ItemMapping, compare_item: ItemMapping, diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 3961eab1b7..ddead36b94 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -27,6 +27,7 @@ Radio, RecommendationFolder, SearchResults, + Series, Track, ) @@ -122,6 +123,11 @@ async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: yield # type: ignore[misc] raise NotImplementedError + async def get_library_series(self) -> AsyncGenerator[Series, None]: + """Retrieve library/subscribed audiobooks from the provider.""" + yield # type: ignore[misc] + raise NotImplementedError + async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: """Retrieve library/subscribed podcasts from the provider.""" yield # type: ignore[misc] @@ -185,6 +191,13 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """ raise NotImplementedError + async def get_series(self, prov_series_id: str) -> Series: + """Get full audiobook details by id. + + Only called if provider supports ProviderFeature.LIBRARY_SERIES. + """ + raise NotImplementedError + async def get_podcast(self, prov_podcast_id: str) -> Podcast: """Get full podcast details by id. @@ -224,6 +237,17 @@ async def get_playlist_tracks( """ raise NotImplementedError + async def get_series_audiobooks( + self, + prov_series_id: str, + page: int = 0, + ) -> Sequence[Audiobook]: + """Get all audiobooks for given series id. + + Only called if provider supports ProviderFeature.LIBRARY_SERIES. + """ + raise NotImplementedError + async def get_podcast_episodes( self, prov_podcast_id: str, @@ -495,6 +519,8 @@ async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemT return await self.get_radio(prov_item_id) if media_type == MediaType.AUDIOBOOK: return await self.get_audiobook(prov_item_id) + if media_type == MediaType.SERIES: + return await self.get_series(prov_item_id) if media_type == MediaType.PODCAST: return await self.get_podcast(prov_item_id) if media_type == MediaType.PODCAST_EPISODE: @@ -715,6 +741,8 @@ async def sync_library(self, media_type: MediaType) -> None: cur_db_ids = await self._sync_library_radios() elif media_type == MediaType.AUDIOBOOK: cur_db_ids = await self._sync_library_audiobooks() + elif media_type == MediaType.SERIES: + cur_db_ids = await self._sync_library_series() else: # this should not happen but catch it anyways raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}") @@ -1245,6 +1273,58 @@ async def _sync_library_podcasts(self) -> set[int]: self._report_sync_task_failure(MediaType.PODCAST, prov_item.uri, err) return cur_db_ids + async def _sync_library_series(self) -> set[int]: + """Sync Library Series to Music Assistant library.""" + self.logger.debug("Start sync of Series to Music Assistant library.") + cur_db_ids: set[int] = set() + item_count = 0 + async for prov_item in self.get_library_series(): + item_count += 1 + self._update_sync_task_item_status(MediaType.SERIES, item_count, prov_item.name) + library_item = await self.mass.music.series.get_library_item_by_prov_mappings( + prov_item.provider_mappings, + ) + try: + if not library_item: + # add item to the library + for prov_map in prov_item.provider_mappings: + prov_map.in_library = True + library_item = await self.mass.music.series.add_item_to_library(prov_item) + 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.series.update_item_in_library( + library_item.item_id, prov_item + ) + elif prov_item.date_added and library_item.date_added != prov_item.date_added: + # update date_added if it changed + library_item = await self.mass.music.series.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.series.set_favorite(library_item.item_id, True) + fallback_genres = ( + set(prov_item.metadata.genres) + if prov_item.metadata and prov_item.metadata.genres + else None + ) + await self._sync_item_genres( + MediaType.SERIES, + prov_item.item_id, + int(library_item.item_id), + fallback_genres, + ) + cur_db_ids.add(int(library_item.item_id)) + await asyncio.sleep(0) # yield to eventloop + except MusicAssistantError as err: + self.logger.warning( + "Skipping sync of series %s - error details: %s", + prov_item.uri, + str(err), + ) + self._report_sync_task_failure(MediaType.SERIES, prov_item.uri, err) + return cur_db_ids + async def _sync_library_radios(self) -> set[int]: """Sync Library Radios to Music Assistant library.""" self.logger.debug("Start sync of Radios to Music Assistant library.") @@ -1303,6 +1383,8 @@ def library_supported(self, media_type: MediaType) -> bool: return ProviderFeature.LIBRARY_RADIOS in self.supported_features if media_type == MediaType.AUDIOBOOK: return ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features + if media_type == MediaType.SERIES: + return ProviderFeature.LIBRARY_SERIES in self.supported_features if media_type == MediaType.PODCAST: return ProviderFeature.LIBRARY_PODCASTS in self.supported_features return False @@ -1398,6 +1480,8 @@ def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemTyp return self.get_library_audiobooks() if media_type == MediaType.PODCAST: return self.get_library_podcasts() + if media_type == MediaType.SERIES: + return self.get_library_series() raise NotImplementedError def _check_provider_mappings( From fdc0de6885a8db70d5204ada1becec6a001adbeb Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:26:09 +0200 Subject: [PATCH 2/9] adapt abs --- .../providers/audiobookshelf/__init__.py | 44 +++++++++++- .../providers/audiobookshelf/parsers.py | 67 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index f6f86d5f25..8f97a1f8b6 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -90,6 +90,7 @@ MediaItemType, Playlist, PodcastEpisode, + Series, UniqueList, ) from music_assistant_models.media_items.media_item import RecommendationFolder @@ -103,6 +104,7 @@ parse_playlist, parse_podcast, parse_podcast_episode, + parse_series, ) from .constants import ( @@ -140,6 +142,7 @@ ProviderFeature.LIBRARY_PODCASTS, ProviderFeature.LIBRARY_AUDIOBOOKS, ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.LIBRARY_SERIES, ProviderFeature.BROWSE, ProviderFeature.RECOMMENDATIONS, } @@ -457,6 +460,42 @@ async def sync_library(self, media_type: MediaType) -> None: user = await self._client.get_my_user() await self._set_playlog_from_user(user) + async def get_library_series(self) -> AsyncGenerator[Series, None]: + """Retrieve series.""" + libraries = await self._client.get_all_libraries() + progresses = (await self._client.get_my_user()).media_progress + for library in libraries: + async for response in self._client.get_library_series(library_id=library.id_): + if not response.results: + break + for abs_series in response.results: + yield parse_series( + abs_series=abs_series, + instance_id=self.instance_id, + domain=self.domain, + progresses=progresses, + ) + + async def get_series(self, prov_series_id: str) -> Series: + """Get a single series.""" + abs_series = await self._client.get_series(series_id=prov_series_id, include_progress=True) + progresses = (await self._client.get_my_user()).media_progress + assert isinstance(abs_series, AbsSeriesWithProgress) # for type checking + return parse_series( + abs_series=abs_series, + instance_id=self.instance_id, + domain=self.domain, + progresses=progresses, + ) + + async def get_series_audiobooks( + self, prov_series_id: str, page: int = 0 + ) -> Sequence[Audiobook]: + """Get audiobooks of a series.""" + if page > 0: + return [] + return await self._browse_series_books(series_id=prov_series_id) + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve playlists from abs.""" for playlist_dict, media_type in zip( @@ -1651,8 +1690,8 @@ async def _browse_narrator_books( return sorted(items, key=lambda x: x.name) - async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemType]: - items = [] + async def _browse_series_books(self, series_id: str) -> Sequence[Audiobook]: + items: list[Audiobook] = [] abs_series = await self._client.get_series(series_id=series_id, include_progress=True) if not isinstance(abs_series, AbsSeriesWithProgress): @@ -1666,6 +1705,7 @@ async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemType]: provider_instance_id_or_domain=self.instance_id, ) if mass_item is not None: + assert isinstance(mass_item, Audiobook) # for type checking items.append(mass_item) return items diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py index 5cd04828fb..1be97ee989 100644 --- a/music_assistant/providers/audiobookshelf/parsers.py +++ b/music_assistant/providers/audiobookshelf/parsers.py @@ -3,6 +3,7 @@ from contextlib import suppress from datetime import datetime +from aioaudiobookshelf.schema.calls_series import SeriesWithProgress as AbsSeriesWithProgress from aioaudiobookshelf.schema.library import ( LibraryItemExpandedBook as AbsLibraryItemExpandedBook, ) @@ -24,6 +25,7 @@ from aioaudiobookshelf.schema.podcast import ( PodcastEpisodeExpanded as AbsPodcastEpisodeExpanded, ) +from aioaudiobookshelf.schema.series_books import SeriesBooksMinified as AbsSeriesBooksMinified from music_assistant_models.enums import ContentType, ImageType, MediaType from music_assistant_models.media_items import Audiobook as MassAudiobook from music_assistant_models.media_items import ( @@ -37,6 +39,71 @@ from music_assistant_models.media_items import Playlist as MassPlaylist from music_assistant_models.media_items import Podcast as MassPodcast from music_assistant_models.media_items import PodcastEpisode as MassPodcastEpisode +from music_assistant_models.media_items import Series as MassSeries + + +def parse_series( + *, + abs_series: AbsSeriesWithProgress | AbsSeriesBooksMinified, + instance_id: str, + domain: str, + progresses: list[AbsMediaProgress], +) -> MassSeries: + """Translate AbsSeries to MassSeries.""" + in_progress = False + progress_percent = 0 + if isinstance(abs_series, AbsSeriesWithProgress): + progress_percent = int( + len(abs_series.progress.library_items_ids_finished) + / len(abs_series.progress.library_item_ids) + * 100 + ) + if len(abs_series.progress.library_items_ids_finished) < len( + abs_series.progress.library_item_ids + ): + for item_id in set(abs_series.progress.library_item_ids).difference( + abs_series.progress.library_items_ids_finished + ): + progress = next( + (x for x in progresses if x.library_item_id == item_id), + None, + ) + if ( + progress is not None + and progress.current_time is not None + and progress.current_time > 0 + ): + in_progress = True + else: + assert isinstance(abs_series, AbsSeriesBooksMinified) # for type checking + total_num_books = len(abs_series.books) + finished_books = 0 + for item_id in [x.id_ for x in abs_series.books]: + progress = next( + (x for x in progresses if x.library_item_id == item_id), + None, + ) + if progress is None: + continue + if progress.is_finished: + finished_books += 1 + elif progress.current_time is not None and progress.current_time > 0: + in_progress = True + if total_num_books != 0: + progress_percent = int(finished_books / total_num_books * 100) + + return MassSeries( + item_id=abs_series.id_, + provider=instance_id, + name=abs_series.name, + provider_mappings={ + ProviderMapping( + item_id=abs_series.id_, provider_domain=domain, provider_instance=instance_id + ) + }, + in_progress=in_progress, + progress_percent=progress_percent, + ) def parse_playlist( From 32f3585b9cfaf2b772da389c64c672fa581c7e6a Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:37:57 +0200 Subject: [PATCH 3/9] Revert "adapt abs" This reverts commit fdc0de6885a8db70d5204ada1becec6a001adbeb. --- .../providers/audiobookshelf/__init__.py | 44 +----------- .../providers/audiobookshelf/parsers.py | 67 ------------------- 2 files changed, 2 insertions(+), 109 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 8f97a1f8b6..f6f86d5f25 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -90,7 +90,6 @@ MediaItemType, Playlist, PodcastEpisode, - Series, UniqueList, ) from music_assistant_models.media_items.media_item import RecommendationFolder @@ -104,7 +103,6 @@ parse_playlist, parse_podcast, parse_podcast_episode, - parse_series, ) from .constants import ( @@ -142,7 +140,6 @@ ProviderFeature.LIBRARY_PODCASTS, ProviderFeature.LIBRARY_AUDIOBOOKS, ProviderFeature.LIBRARY_PLAYLISTS, - ProviderFeature.LIBRARY_SERIES, ProviderFeature.BROWSE, ProviderFeature.RECOMMENDATIONS, } @@ -460,42 +457,6 @@ async def sync_library(self, media_type: MediaType) -> None: user = await self._client.get_my_user() await self._set_playlog_from_user(user) - async def get_library_series(self) -> AsyncGenerator[Series, None]: - """Retrieve series.""" - libraries = await self._client.get_all_libraries() - progresses = (await self._client.get_my_user()).media_progress - for library in libraries: - async for response in self._client.get_library_series(library_id=library.id_): - if not response.results: - break - for abs_series in response.results: - yield parse_series( - abs_series=abs_series, - instance_id=self.instance_id, - domain=self.domain, - progresses=progresses, - ) - - async def get_series(self, prov_series_id: str) -> Series: - """Get a single series.""" - abs_series = await self._client.get_series(series_id=prov_series_id, include_progress=True) - progresses = (await self._client.get_my_user()).media_progress - assert isinstance(abs_series, AbsSeriesWithProgress) # for type checking - return parse_series( - abs_series=abs_series, - instance_id=self.instance_id, - domain=self.domain, - progresses=progresses, - ) - - async def get_series_audiobooks( - self, prov_series_id: str, page: int = 0 - ) -> Sequence[Audiobook]: - """Get audiobooks of a series.""" - if page > 0: - return [] - return await self._browse_series_books(series_id=prov_series_id) - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve playlists from abs.""" for playlist_dict, media_type in zip( @@ -1690,8 +1651,8 @@ async def _browse_narrator_books( return sorted(items, key=lambda x: x.name) - async def _browse_series_books(self, series_id: str) -> Sequence[Audiobook]: - items: list[Audiobook] = [] + async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemType]: + items = [] abs_series = await self._client.get_series(series_id=series_id, include_progress=True) if not isinstance(abs_series, AbsSeriesWithProgress): @@ -1705,7 +1666,6 @@ async def _browse_series_books(self, series_id: str) -> Sequence[Audiobook]: provider_instance_id_or_domain=self.instance_id, ) if mass_item is not None: - assert isinstance(mass_item, Audiobook) # for type checking items.append(mass_item) return items diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py index 1be97ee989..5cd04828fb 100644 --- a/music_assistant/providers/audiobookshelf/parsers.py +++ b/music_assistant/providers/audiobookshelf/parsers.py @@ -3,7 +3,6 @@ from contextlib import suppress from datetime import datetime -from aioaudiobookshelf.schema.calls_series import SeriesWithProgress as AbsSeriesWithProgress from aioaudiobookshelf.schema.library import ( LibraryItemExpandedBook as AbsLibraryItemExpandedBook, ) @@ -25,7 +24,6 @@ from aioaudiobookshelf.schema.podcast import ( PodcastEpisodeExpanded as AbsPodcastEpisodeExpanded, ) -from aioaudiobookshelf.schema.series_books import SeriesBooksMinified as AbsSeriesBooksMinified from music_assistant_models.enums import ContentType, ImageType, MediaType from music_assistant_models.media_items import Audiobook as MassAudiobook from music_assistant_models.media_items import ( @@ -39,71 +37,6 @@ from music_assistant_models.media_items import Playlist as MassPlaylist from music_assistant_models.media_items import Podcast as MassPodcast from music_assistant_models.media_items import PodcastEpisode as MassPodcastEpisode -from music_assistant_models.media_items import Series as MassSeries - - -def parse_series( - *, - abs_series: AbsSeriesWithProgress | AbsSeriesBooksMinified, - instance_id: str, - domain: str, - progresses: list[AbsMediaProgress], -) -> MassSeries: - """Translate AbsSeries to MassSeries.""" - in_progress = False - progress_percent = 0 - if isinstance(abs_series, AbsSeriesWithProgress): - progress_percent = int( - len(abs_series.progress.library_items_ids_finished) - / len(abs_series.progress.library_item_ids) - * 100 - ) - if len(abs_series.progress.library_items_ids_finished) < len( - abs_series.progress.library_item_ids - ): - for item_id in set(abs_series.progress.library_item_ids).difference( - abs_series.progress.library_items_ids_finished - ): - progress = next( - (x for x in progresses if x.library_item_id == item_id), - None, - ) - if ( - progress is not None - and progress.current_time is not None - and progress.current_time > 0 - ): - in_progress = True - else: - assert isinstance(abs_series, AbsSeriesBooksMinified) # for type checking - total_num_books = len(abs_series.books) - finished_books = 0 - for item_id in [x.id_ for x in abs_series.books]: - progress = next( - (x for x in progresses if x.library_item_id == item_id), - None, - ) - if progress is None: - continue - if progress.is_finished: - finished_books += 1 - elif progress.current_time is not None and progress.current_time > 0: - in_progress = True - if total_num_books != 0: - progress_percent = int(finished_books / total_num_books * 100) - - return MassSeries( - item_id=abs_series.id_, - provider=instance_id, - name=abs_series.name, - provider_mappings={ - ProviderMapping( - item_id=abs_series.id_, provider_domain=domain, provider_instance=instance_id - ) - }, - in_progress=in_progress, - progress_percent=progress_percent, - ) def parse_playlist( From 762c68e714c976cacad06b13b2176782e4cca8f0 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:38:11 +0200 Subject: [PATCH 4/9] Revert "add series controller" This reverts commit 2ace3b490a81095c7ba65cea530cd29127344cfd. --- music_assistant/constants.py | 10 - music_assistant/controllers/config.py | 3 - music_assistant/controllers/media/base.py | 4 +- music_assistant/controllers/media/series.py | 316 -------------------- music_assistant/controllers/music.py | 25 -- music_assistant/helpers/compare.py | 33 -- music_assistant/models/music_provider.py | 84 ------ 7 files changed, 1 insertion(+), 474 deletions(-) delete mode 100644 music_assistant/controllers/media/series.py diff --git a/music_assistant/constants.py b/music_assistant/constants.py index afad1f153b..cc21b15442 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -150,7 +150,6 @@ DB_TABLE_PLAYLISTS: Final[str] = "playlists" DB_TABLE_RADIOS: Final[str] = "radios" DB_TABLE_AUDIOBOOKS: Final[str] = "audiobooks" -DB_TABLE_SERIES: Final[str] = "series" DB_TABLE_PODCASTS: Final[str] = "podcasts" DB_TABLE_CACHE: Final[str] = "cache" DB_TABLE_SETTINGS: Final[str] = "settings" @@ -730,15 +729,6 @@ def create_output_codec_config_entry( default_value=True, category="sync_options", ) -CONF_ENTRY_LIBRARY_SYNC_SERIES = ConfigEntry( - key="library_sync_seriess", - type=ConfigEntryType.BOOLEAN, - label="Sync Library Series from this source to Music Assistant", - description="Whether to import (favourited/in-library) series from this " - "source to the Music Assistant Library.", - default_value=True, - category="sync_options", -) CONF_ENTRY_LIBRARY_SYNC_RADIOS = ConfigEntry( key="library_sync_radios", type=ConfigEntryType.BOOLEAN, diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 36597f9e26..180c526808 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -68,7 +68,6 @@ CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS, CONF_ENTRY_LIBRARY_SYNC_PODCASTS, CONF_ENTRY_LIBRARY_SYNC_RADIOS, - CONF_ENTRY_LIBRARY_SYNC_SERIES, CONF_ENTRY_LIBRARY_SYNC_TRACKS, CONF_ENTRY_OUTPUT_CHANNELS, CONF_ENTRY_OUTPUT_CODEC, @@ -424,8 +423,6 @@ async def get_provider_config_entries( extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS) if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features: extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS) - if ProviderFeature.LIBRARY_SERIES in supported_features: - extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_SERIES) if ProviderFeature.LIBRARY_PODCASTS in supported_features: extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PODCASTS) if ProviderFeature.LIBRARY_RADIOS in supported_features: diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index 24b39f3a11..69e5613d35 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -117,9 +117,7 @@ def __init__(self, mass: MusicAssistant) -> None: FROM {self.db_table} """ self.logger = logging.getLogger(f"{MASS_LOGGER_NAME}.music.{self.media_type.value}") # register (base) api handlers - self.api_base = api_base = ( - f"{self.media_type}s" if self.media_type != MediaType.SERIES else self.media_type - ) + self.api_base = api_base = f"{self.media_type}s" self.mass.register_api_command(f"music/{api_base}/count", self.library_count) self.mass.register_api_command(f"music/{api_base}/library_items", self.library_items) self.mass.register_api_command(f"music/{api_base}/get", self.get) diff --git a/music_assistant/controllers/media/series.py b/music_assistant/controllers/media/series.py deleted file mode 100644 index 10ad807741..0000000000 --- a/music_assistant/controllers/media/series.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Manage MediaItems of type Series.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Sequence -from typing import TYPE_CHECKING, Any, cast - -from music_assistant_models.enums import MediaType, ProviderFeature -from music_assistant_models.errors import MediaNotFoundError -from music_assistant_models.media_items import ( - Audiobook, - ProviderMapping, - Series, - UniqueList, -) - -from music_assistant.constants import DB_TABLE_SERIES -from music_assistant.controllers.media.base import MediaControllerBase -from music_assistant.helpers.compare import ( - compare_media_item, - compare_series, - create_safe_string, - loose_compare_strings, -) -from music_assistant.helpers.database import UNSET -from music_assistant.helpers.json import serialize_to_json -from music_assistant.helpers.util import guard_single_request -from music_assistant.models.music_provider import MusicProvider - -if TYPE_CHECKING: - from music_assistant_models.media_items import Track - - from music_assistant import MusicAssistant - - -class SeriesController(MediaControllerBase[Series]): - """Controller managing MediaItems of type Series.""" - - db_table = DB_TABLE_SERIES - media_type = MediaType.SERIES - item_cls = Series - - def __init__(self, mass: MusicAssistant) -> None: - """Initialize class.""" - super().__init__(mass) - # register (extra) api handlers - api_base = self.api_base - self.mass.register_api_command(f"music/{api_base}/audiobooks", self.audiobooks) - - async def library_items( - self, - favorite: bool | None = None, - search: str | None = None, - limit: int = 500, - offset: int = 0, - order_by: str = "sort_name", - provider: str | list[str] | None = None, - genre: int | list[int] | None = None, - **kwargs: Any, - ) -> list[Series]: - """Get in-database series. - - :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 genre: Filter by genre id(s). - """ - return await self.get_library_items_by_query( - favorite=favorite, - search=search, - genre_ids=genre, - limit=limit, - offset=offset, - order_by=order_by, - provider_filter=self._ensure_provider_filter(provider), - in_library_only=True, - ) - - async def audiobooks( - self, - item_id: str, - provider_instance_id_or_domain: str, - ) -> AsyncGenerator[Audiobook, None]: - """Return audiobooks for the given provider series id.""" - # always check if we have a library item for this series - if provider_instance_id_or_domain == "library": - library_series = await self.get_library_item(item_id) - if not library_series: - raise MediaNotFoundError(f"Series {item_id} not found in library") - provider_instance_id_or_domain, item_id = self._select_provider_id(library_series) - - # series audiobooks are not stored in the db, - page = 0 - while True: - audiobooks = await self._get_provider_series_audiobooks( - item_id, - provider_instance_id_or_domain, - page=page, - ) - if not audiobooks: - break - for audiobook in audiobooks: - yield audiobook - page += 1 - - @guard_single_request # type: ignore[type-var] # TODO: fix typing in util.py - async def _get_provider_series_audiobooks( - self, - item_id: str, - provider_instance_id_or_domain: str, - page: int = 0, - force_refresh: bool = False, - ) -> Sequence[Audiobook]: - """Return playlist tracks for the given provider playlist id.""" - assert provider_instance_id_or_domain != "library" - if not (provider := self.mass.get_provider(provider_instance_id_or_domain)): - return [] - provider = cast("MusicProvider", provider) - async with self.mass.cache.handle_refresh(force_refresh): - return await provider.get_series_audiobooks(item_id, page=page) - - async def versions( - self, - item_id: str, - provider_instance_id_or_domain: str, - ) -> UniqueList[Series]: - """Return all versions of an podcast we can find on all providers.""" - series = await self.get_provider_item(item_id, provider_instance_id_or_domain) - search_query = series.name - result: UniqueList[Series] = UniqueList() - for provider_id in self.mass.music.get_unique_providers(): - provider = self.mass.get_provider(provider_id) - if not isinstance(provider, MusicProvider): - continue - if not provider.library_supported(MediaType.SERIES): - continue - result.extend( - prov_item - for prov_item in await self.search(search_query, provider_id) - if loose_compare_strings(series.name, prov_item.name) - # make sure that the 'base' version is NOT included - and not series.provider_mappings.intersection(prov_item.provider_mappings) - ) - return result - - async def _add_library_item(self, item: Series, overwrite_existing: bool = False) -> int: - """Add a new record to the database.""" - db_id = await self.mass.music.database.insert( - self.db_table, - { - "name": item.name, - "sort_name": item.sort_name, - "version": item.version, - "favorite": item.favorite, - "metadata": serialize_to_json(item.metadata), - "external_ids": serialize_to_json(item.external_ids), - "search_name": create_safe_string(item.name, True, True), - "search_sort_name": create_safe_string(item.sort_name or "", True, True), - "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET, - "in_progress": item.in_progress, - "progress_percent": item.progress_percent, - }, - ) - # update/set provider_mappings table - await self.set_provider_mappings(db_id, item.provider_mappings) - self.logger.debug("added %s to database (id: %s)", item.name, db_id) - return db_id - - async def _update_library_item( - self, item_id: str | int, update: Series, overwrite: bool = False - ) -> None: - """Update existing record in the database.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_library_item(db_id) - metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata) - cur_item.external_ids.update(update.external_ids) - name = update.name if overwrite else cur_item.name - sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name - await self.mass.music.database.update( - self.db_table, - {"item_id": db_id}, - { - "name": name, - "sort_name": sort_name, - "version": update.version if overwrite else cur_item.version or update.version, - "metadata": serialize_to_json(metadata), - "external_ids": serialize_to_json( - update.external_ids if overwrite else cur_item.external_ids - ), - "search_name": create_safe_string(name, True, True), - "search_sort_name": create_safe_string(sort_name or "", True, True), - "timestamp_added": int(update.date_added.timestamp()) - if update.date_added - else UNSET, - "in_progress": update.in_progress, - "progress_percent": update.progress_percent, - }, - ) - # update/set provider_mappings table - provider_mappings = ( - update.provider_mappings - if overwrite - else {*update.provider_mappings, *cur_item.provider_mappings} - ) - await self.set_provider_mappings(db_id, provider_mappings, overwrite) - self.logger.debug("updated %s in database: (id %s)", update.name, db_id) - - async def radio_mode_base_tracks( - self, - item: Series, - preferred_provider_instances: list[str] | None = None, - ) -> list[Track]: - """ - Get the list of base tracks from the controller used to calculate the dynamic radio. - - :param item: The Podcast to get base tracks for. - :param preferred_provider_instances: List of preferred provider instance IDs to use. - """ - msg = "Dynamic tracks not supported for Series MediaItem" - raise NotImplementedError(msg) - - async def match_provider( - self, db_series: Series, provider: MusicProvider, strict: bool = True - ) -> list[ProviderMapping]: - """ - Try to find match on (streaming) provider for the provided (database) series. - - This is used to link objects of different providers/qualities together. - """ - self.logger.debug( - "Trying to match series %s on provider %s", - db_series.name, - provider.name, - ) - matches: list[ProviderMapping] = [] - search_str = db_series.name - search_result = await self.search(search_str, provider.instance_id) - for search_result_item in search_result: - if not search_result_item.available: - continue - if not compare_media_item(db_series, search_result_item, strict=strict): - continue - # we must fetch the full podcast version, search results can be simplified objects - prov_podcast = await self.get_provider_item( - search_result_item.item_id, - search_result_item.provider, - fallback=search_result_item, - ) - if compare_series(db_series, prov_podcast, strict=strict): - # 100% match - matches.extend(prov_podcast.provider_mappings) - if not matches: - self.logger.debug( - "Could not find match for Podcast %s on provider %s", - db_series.name, - provider.name, - ) - return matches - - async def match_providers(self, db_item: Series) -> None: - """Try to find match on all (streaming) providers for the provided (database) series. - - This is used to link objects of different providers/qualities together. - """ - if db_item.provider != "library": - return # Matching only supported for database items - - # try to find match on all providers - cur_provider_domains = {x.provider_domain for x in db_item.provider_mappings} - for provider in self.mass.music.providers: - if provider.domain in cur_provider_domains: - continue - if ProviderFeature.SEARCH not in provider.supported_features: - continue - if not provider.library_supported(MediaType.SERIES): - continue - if not provider.is_streaming_provider: - # matching on unique providers is pointless as they push (all) their content to MA - continue - if match := await self.match_provider(db_item, provider): - # 100% match, we update the db with the additional provider mapping(s) - await self.add_provider_mappings(db_item.item_id, match) - cur_provider_domains.add(provider.domain) - - async def get( - self, item_id: str, provider_instance_id_or_domain: str, allow_update_metadata: bool = True - ) -> Series: - """Get series, and enhance with progress if available.""" - series = await super().get(item_id, provider_instance_id_or_domain, allow_update_metadata) - if series.in_progress is not None and series.progress_percent is not None: - return series - - total_books = 0 - total_books_finished = 0 - in_progress = False - async for book in self.audiobooks(item_id, provider_instance_id_or_domain): - total_books += 1 - if book.fully_played: - total_books_finished += 1 - elif ( - book.fully_played is not None - and not book.fully_played - and book.resume_position_ms is not None - and book.resume_position_ms != 0 - ): - in_progress = True - - if total_books == 0: - return series - - series.in_progress = in_progress if total_books != total_books_finished else False - series.progress_percent = int(total_books_finished / total_books * 100) - return series diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index a3a9e34cbd..08220aabb4 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -66,7 +66,6 @@ DB_TABLE_PODCASTS, DB_TABLE_PROVIDER_MAPPINGS, DB_TABLE_RADIOS, - DB_TABLE_SERIES, DB_TABLE_SETTINGS, DB_TABLE_SMART_FADES_ANALYSIS, DB_TABLE_TRACK_ARTISTS, @@ -74,7 +73,6 @@ DEFAULT_GENRE_MAPPING, PROVIDERS_WITH_SHAREABLE_URLS, ) -from music_assistant.controllers.media.series import SeriesController from music_assistant.controllers.streams.smart_fades.fades import SMART_CROSSFADE_DURATION from music_assistant.controllers.tasks.context import update_current_task_progress_text from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user @@ -141,7 +139,6 @@ def __init__(self, mass: MusicAssistant) -> None: self.audiobooks = AudiobooksController(self.mass) self.podcasts = PodcastsController(self.mass) self.genres = GenreController(self.mass) - self.series = SeriesController(self.mass) self._database: DatabaseConnection | None = None self._sync_lock = asyncio.Lock() self.manifest.name = "Music controller" @@ -1572,7 +1569,6 @@ def get_controller( | AudiobooksController | PodcastsController | GenreController - | SeriesController ): """Return controller for MediaType.""" if media_type == MediaType.ARTIST: @@ -1593,8 +1589,6 @@ def get_controller( return self.podcasts if media_type == MediaType.GENRE: return self.genres - if media_type == MediaType.SERIES: - return self.series raise NotImplementedError def get_provider_instances( @@ -3056,25 +3050,6 @@ async def __create_database_tables(self) -> None: UNIQUE(item_id,provider,aa_provider_domain,media_type));""" ) - await self.database.execute( - f""" - CREATE TABLE IF NOT EXISTS {DB_TABLE_SERIES}( - [item_id] INTEGER PRIMARY KEY AUTOINCREMENT, - [name] TEXT NOT NULL, - [sort_name] TEXT NOT NULL, - [version] TEXT, - [favorite] BOOLEAN NOT NULL DEFAULT 0, - [metadata] json NOT NULL, - [external_ids] json NOT NULL, - [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)), - [timestamp_modified] INTEGER NOT NULL DEFAULT 0, - [search_name] TEXT NOT NULL, - [search_sort_name] TEXT NOT NULL, - [in_progress] BOOLEAN DEFAULT NULL, - [progress_percent] INTEGER DEFAULT NULL - );""" - ) - await self.database.commit() async def __create_database_indexes(self) -> None: diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index 524f68e036..a4a51ca04b 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -18,7 +18,6 @@ Playlist, Podcast, Radio, - Series, Track, ) @@ -374,38 +373,6 @@ def compare_podcast( ) -def compare_series( - base_item: Series | ItemMapping, - compare_item: Series | ItemMapping, - strict: bool = True, -) -> bool | None: - """Compare two Podcast items and return True if they match.""" - # return early on exact item_id match - if compare_item_ids(base_item, compare_item): - return True - - # return early on (un)matched external id - for ext_id in ( - ExternalID.ASIN, - ExternalID.BARCODE, - ): - external_id_match = compare_external_ids( - base_item.external_ids, compare_item.external_ids, ext_id - ) - if external_id_match is not None: - return external_id_match - - # compare version - if not compare_version(base_item.version, compare_item.version): - return False - # compare name - if not compare_strings(base_item.name, compare_item.name, strict=True): - return False - if not strict and (isinstance(base_item, ItemMapping) or isinstance(compare_item, ItemMapping)): - return True - return compare_strings(base_item.name, compare_item.name, strict=True) - - def compare_item_mapping( base_item: ItemMapping, compare_item: ItemMapping, diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index ddead36b94..3961eab1b7 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -27,7 +27,6 @@ Radio, RecommendationFolder, SearchResults, - Series, Track, ) @@ -123,11 +122,6 @@ async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: yield # type: ignore[misc] raise NotImplementedError - async def get_library_series(self) -> AsyncGenerator[Series, None]: - """Retrieve library/subscribed audiobooks from the provider.""" - yield # type: ignore[misc] - raise NotImplementedError - async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: """Retrieve library/subscribed podcasts from the provider.""" yield # type: ignore[misc] @@ -191,13 +185,6 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """ raise NotImplementedError - async def get_series(self, prov_series_id: str) -> Series: - """Get full audiobook details by id. - - Only called if provider supports ProviderFeature.LIBRARY_SERIES. - """ - raise NotImplementedError - async def get_podcast(self, prov_podcast_id: str) -> Podcast: """Get full podcast details by id. @@ -237,17 +224,6 @@ async def get_playlist_tracks( """ raise NotImplementedError - async def get_series_audiobooks( - self, - prov_series_id: str, - page: int = 0, - ) -> Sequence[Audiobook]: - """Get all audiobooks for given series id. - - Only called if provider supports ProviderFeature.LIBRARY_SERIES. - """ - raise NotImplementedError - async def get_podcast_episodes( self, prov_podcast_id: str, @@ -519,8 +495,6 @@ async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemT return await self.get_radio(prov_item_id) if media_type == MediaType.AUDIOBOOK: return await self.get_audiobook(prov_item_id) - if media_type == MediaType.SERIES: - return await self.get_series(prov_item_id) if media_type == MediaType.PODCAST: return await self.get_podcast(prov_item_id) if media_type == MediaType.PODCAST_EPISODE: @@ -741,8 +715,6 @@ async def sync_library(self, media_type: MediaType) -> None: cur_db_ids = await self._sync_library_radios() elif media_type == MediaType.AUDIOBOOK: cur_db_ids = await self._sync_library_audiobooks() - elif media_type == MediaType.SERIES: - cur_db_ids = await self._sync_library_series() else: # this should not happen but catch it anyways raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}") @@ -1273,58 +1245,6 @@ async def _sync_library_podcasts(self) -> set[int]: self._report_sync_task_failure(MediaType.PODCAST, prov_item.uri, err) return cur_db_ids - async def _sync_library_series(self) -> set[int]: - """Sync Library Series to Music Assistant library.""" - self.logger.debug("Start sync of Series to Music Assistant library.") - cur_db_ids: set[int] = set() - item_count = 0 - async for prov_item in self.get_library_series(): - item_count += 1 - self._update_sync_task_item_status(MediaType.SERIES, item_count, prov_item.name) - library_item = await self.mass.music.series.get_library_item_by_prov_mappings( - prov_item.provider_mappings, - ) - try: - if not library_item: - # add item to the library - for prov_map in prov_item.provider_mappings: - prov_map.in_library = True - library_item = await self.mass.music.series.add_item_to_library(prov_item) - 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.series.update_item_in_library( - library_item.item_id, prov_item - ) - elif prov_item.date_added and library_item.date_added != prov_item.date_added: - # update date_added if it changed - library_item = await self.mass.music.series.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.series.set_favorite(library_item.item_id, True) - fallback_genres = ( - set(prov_item.metadata.genres) - if prov_item.metadata and prov_item.metadata.genres - else None - ) - await self._sync_item_genres( - MediaType.SERIES, - prov_item.item_id, - int(library_item.item_id), - fallback_genres, - ) - cur_db_ids.add(int(library_item.item_id)) - await asyncio.sleep(0) # yield to eventloop - except MusicAssistantError as err: - self.logger.warning( - "Skipping sync of series %s - error details: %s", - prov_item.uri, - str(err), - ) - self._report_sync_task_failure(MediaType.SERIES, prov_item.uri, err) - return cur_db_ids - async def _sync_library_radios(self) -> set[int]: """Sync Library Radios to Music Assistant library.""" self.logger.debug("Start sync of Radios to Music Assistant library.") @@ -1383,8 +1303,6 @@ def library_supported(self, media_type: MediaType) -> bool: return ProviderFeature.LIBRARY_RADIOS in self.supported_features if media_type == MediaType.AUDIOBOOK: return ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features - if media_type == MediaType.SERIES: - return ProviderFeature.LIBRARY_SERIES in self.supported_features if media_type == MediaType.PODCAST: return ProviderFeature.LIBRARY_PODCASTS in self.supported_features return False @@ -1480,8 +1398,6 @@ def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemTyp return self.get_library_audiobooks() if media_type == MediaType.PODCAST: return self.get_library_podcasts() - if media_type == MediaType.SERIES: - return self.get_library_series() raise NotImplementedError def _check_provider_mappings( From d86574d495b510ea6cdc1be007fdb498aa4e8ea3 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:24:53 +0200 Subject: [PATCH 5/9] adapt abs --- music_assistant/providers/audiobookshelf/parsers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py index 5cd04828fb..3b5bfe5c61 100644 --- a/music_assistant/providers/audiobookshelf/parsers.py +++ b/music_assistant/providers/audiobookshelf/parsers.py @@ -31,6 +31,7 @@ ItemMapping, MediaItemChapter, MediaItemImage, + MediaItemSeries, ProviderMapping, UniqueList, ) @@ -221,7 +222,7 @@ def parse_podcast_episode( def parse_audiobook( *, - abs_audiobook: AbsLibraryItemExpandedBook | AbsLibraryItemMinifiedBook, + abs_audiobook: AbsLibraryItemExpandedBook, instance_id: str, domain: str, token: str | None, @@ -266,6 +267,15 @@ def parse_audiobook( year=int(abs_audiobook.media.metadata.published_year), month=1, day=1 ) + book_series: list[MediaItemSeries] = [] + for abs_series_sequence in abs_audiobook.media.metadata.series: + book_series.append( + MediaItemSeries(title=abs_series_sequence.name, sequence=abs_series_sequence.sequence) + ) + + if book_series: + mass_audiobook.metadata.series = UniqueList(book_series) + if abs_audiobook.media.metadata.genres is not None: mass_audiobook.metadata.genres = set(abs_audiobook.media.metadata.genres) From 2329e11ab2a9c29429f807a1e872241e786cf9c7 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:25:05 +0200 Subject: [PATCH 6/9] add series endpoint --- .../controllers/media/audiobooks.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 2de981702a..197866b6ee 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any +from music_assistant_models.api import AudiobookSeries from music_assistant_models.enums import MediaType, ProviderFeature from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList @@ -63,6 +64,7 @@ def __init__(self, mass: MusicAssistant) -> None: # register (extra) api handlers api_base = self.api_base self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions) + self.mass.register_api_command(f"music/{api_base}/get_series", self.series) async def library_items( self, @@ -362,3 +364,47 @@ async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None: }, allow_replace=True, ) + + async def series(self) -> list[AudiobookSeries]: + """Get all available audiobook series.""" + # key is the series' title + series: dict[str, list[Audiobook]] = {} + audiobooks_with_series = await self.get_library_items_by_query( + extra_query_parts=[ + "WHERE json_extract(audiobooks.metadata, '$.series') IS NOT NULL " + "AND json_extract(audiobooks.metadata, '$.series') != '[]'", + ] + ) + for audiobook in audiobooks_with_series: + if audiobook.metadata.series is None: + # this should never happen + continue + for series_info in audiobook.metadata.series: + audiobook_list = series.get(series_info.title, []) + audiobook_list.append(audiobook) + series[series_info.title] = audiobook_list + + result: list[AudiobookSeries] = [] + # Sort series, first by number then alphabetically + for series_title, audiobook_list in series.items(): + audiobooks_with_number: list[tuple[Audiobook, float]] = [] + audiobooks_with_string: list[tuple[Audiobook, str]] = [] + audiobooks_with_none: list[Audiobook] = [] + for audiobook in audiobook_list: + assert audiobook.metadata.series is not None # for type checking + series_info = next(x for x in audiobook.metadata.series if x.title == series_title) + if series_info.sequence is None: + audiobooks_with_none.append(audiobook) + continue + try: + sort_by = float(series_info.sequence) + audiobooks_with_number.append((audiobook, sort_by)) + except ValueError: + audiobooks_with_string.append((audiobook, series_info.sequence)) + final_list = [x[0] for x in sorted(audiobooks_with_number, key=lambda x: x[1])] + final_list.extend([x[0] for x in sorted(audiobooks_with_string, key=lambda x: x[1])]) + final_list.extend(audiobooks_with_none) + + result.append(AudiobookSeries(title=series_title, audiobooks=final_list)) + + return result From b5e06d8f199d8498fddf561a9e29b716433b3bcb Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:59:45 +0200 Subject: [PATCH 7/9] filter out series in library items --- .../controllers/media/audiobooks.py | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 197866b6ee..21c1319d1b 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -75,6 +75,7 @@ async def library_items( order_by: str = "sort_name", provider: str | list[str] | None = None, genre: int | list[int] | None = None, + without_series: bool | None = None, **kwargs: Any, ) -> list[Audiobook]: """Get in-database audiobooks. @@ -86,9 +87,16 @@ async def library_items( :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added'). :param provider: Filter by provider instance ID (single string or list). :param genre: Filter by genre id(s). + :param without_series: Do not return audiobooks which are part of a series """ extra_query_params: dict[str, Any] = {} extra_query_parts: list[str] = [] + self.logger.error(without_series) + if without_series: + extra_query_parts = [ + "WHERE json_extract(audiobooks.metadata, '$.series') IS NULL " + "OR json_extract(audiobooks.metadata, '$.series') = '[]'", + ] result = await self.get_library_items_by_query( favorite=favorite, search=search, @@ -365,28 +373,38 @@ async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None: allow_replace=True, ) - async def series(self) -> list[AudiobookSeries]: - """Get all available audiobook series.""" + async def series( + self, + limit: int = 500, + offset: int = 0, + ) -> list[AudiobookSeries]: + """Get all available audiobook series. + + :param limit: Maximum number of items to return. + :param offset: Number of items to skip. + """ # key is the series' title - series: dict[str, list[Audiobook]] = {} + series_dict: dict[str, list[Audiobook]] = {} audiobooks_with_series = await self.get_library_items_by_query( + limit=limit, + offset=offset, extra_query_parts=[ "WHERE json_extract(audiobooks.metadata, '$.series') IS NOT NULL " "AND json_extract(audiobooks.metadata, '$.series') != '[]'", - ] + ], ) for audiobook in audiobooks_with_series: if audiobook.metadata.series is None: # this should never happen continue for series_info in audiobook.metadata.series: - audiobook_list = series.get(series_info.title, []) + audiobook_list = series_dict.get(series_info.title, []) audiobook_list.append(audiobook) - series[series_info.title] = audiobook_list + series_dict[series_info.title] = audiobook_list result: list[AudiobookSeries] = [] # Sort series, first by number then alphabetically - for series_title, audiobook_list in series.items(): + for series_title, audiobook_list in series_dict.items(): audiobooks_with_number: list[tuple[Audiobook, float]] = [] audiobooks_with_string: list[tuple[Audiobook, str]] = [] audiobooks_with_none: list[Audiobook] = [] From b116221446575f26a7d9bc0ff8bddf50973e830b Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:08:39 +0200 Subject: [PATCH 8/9] lint --- music_assistant/controllers/media/audiobooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 21c1319d1b..f5d6a1c2ce 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, Any -from music_assistant_models.api import AudiobookSeries from music_assistant_models.enums import MediaType, ProviderFeature from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList +from music_assistant_models.media_items.helpers import AudiobookSeries from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG from music_assistant.controllers.media.base import MediaControllerBase @@ -418,7 +418,7 @@ async def series( sort_by = float(series_info.sequence) audiobooks_with_number.append((audiobook, sort_by)) except ValueError: - audiobooks_with_string.append((audiobook, series_info.sequence)) + audiobooks_with_string.append((audiobook, str(series_info.sequence))) final_list = [x[0] for x in sorted(audiobooks_with_number, key=lambda x: x[1])] final_list.extend([x[0] for x in sorted(audiobooks_with_string, key=lambda x: x[1])]) final_list.extend(audiobooks_with_none) From 61f98d92ac0fc731ff6d9b6b8d210cab7fb1022c Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:46:12 +0200 Subject: [PATCH 9/9] feedback --- music_assistant/controllers/media/audiobooks.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 682ba6d237..8b344bd18e 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -96,8 +96,8 @@ async def library_items( extra_join_parts = [f"AND playlog.userid = '{session_user.user_id}'"] if without_series: extra_query_parts = [ - "WHERE json_extract(audiobooks.metadata, '$.series') IS NULL " - "OR json_extract(audiobooks.metadata, '$.series') = '[]'", + "WHERE (json_extract(audiobooks.metadata, '$.series') IS NULL " + "OR json_extract(audiobooks.metadata, '$.series') = '[]')", ] result = await self.get_library_items_by_query( favorite=favorite, @@ -379,8 +379,6 @@ async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None: async def series( self, - limit: int = 500, - offset: int = 0, ) -> list[AudiobookSeries]: """Get all available audiobook series. @@ -390,8 +388,6 @@ async def series( # key is the series' title series_dict: dict[str, list[Audiobook]] = {} audiobooks_with_series = await self.get_library_items_by_query( - limit=limit, - offset=offset, extra_query_parts=[ "WHERE json_extract(audiobooks.metadata, '$.series') IS NOT NULL " "AND json_extract(audiobooks.metadata, '$.series') != '[]'",