music-assistant-server

14.7 KBPY
audiobooks.py
14.7 KB340 lines • python
1"""Manage MediaItems of type Audiobook."""
2
3from __future__ import annotations
4
5from typing import TYPE_CHECKING, Any
6
7from music_assistant_models.enums import MediaType, ProviderFeature
8from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList
9
10from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG
11from music_assistant.controllers.media.base import MediaControllerBase
12from music_assistant.helpers.compare import (
13    compare_audiobook,
14    compare_media_item,
15    create_safe_string,
16    loose_compare_strings,
17)
18from music_assistant.helpers.database import UNSET
19from music_assistant.helpers.datetime import utc_timestamp
20from music_assistant.helpers.json import serialize_to_json
21from music_assistant.helpers.util import parse_optional_bool
22from music_assistant.models.music_provider import MusicProvider
23
24if TYPE_CHECKING:
25    from music_assistant_models.media_items import Track
26
27    from music_assistant import MusicAssistant
28
29
30class AudiobooksController(MediaControllerBase[Audiobook]):
31    """Controller managing MediaItems of type Audiobook."""
32
33    db_table = DB_TABLE_AUDIOBOOKS
34    media_type = MediaType.AUDIOBOOK
35    item_cls = Audiobook
36
37    def __init__(self, mass: MusicAssistant) -> None:
38        """Initialize class."""
39        super().__init__(mass)
40        self.base_query = """
41        SELECT
42            audiobooks.*,
43            (SELECT JSON_GROUP_ARRAY(
44                json_object(
45                'item_id', audiobook_pm.provider_item_id,
46                    'provider_domain', audiobook_pm.provider_domain,
47                        'provider_instance', audiobook_pm.provider_instance,
48                        'available', audiobook_pm.available,
49                        'audio_format', json(audiobook_pm.audio_format),
50                        'url', audiobook_pm.url,
51                        'details', audiobook_pm.details,
52                        'in_library', audiobook_pm.in_library,
53                        'is_unique', audiobook_pm.is_unique
54                )) FROM provider_mappings audiobook_pm WHERE audiobook_pm.item_id = audiobooks.item_id AND audiobook_pm.media_type = 'audiobook') AS provider_mappings,
55            playlog.fully_played AS fully_played,
56            playlog.seconds_played AS seconds_played,
57            playlog.seconds_played * 1000 as resume_position_ms
58            FROM audiobooks
59            LEFT JOIN playlog ON playlog.item_id = audiobooks.item_id AND playlog.media_type = 'audiobook'
60            """  # noqa: E501
61        # register (extra) api handlers
62        api_base = self.api_base
63        self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions)
64
65    async def library_items(
66        self,
67        favorite: bool | None = None,
68        search: str | None = None,
69        limit: int = 500,
70        offset: int = 0,
71        order_by: str = "sort_name",
72        provider: str | list[str] | None = None,
73        genre: int | list[int] | None = None,
74        **kwargs: Any,
75    ) -> list[Audiobook]:
76        """Get in-database audiobooks.
77
78        :param favorite: Filter by favorite status.
79        :param search: Filter by search query.
80        :param limit: Maximum number of items to return.
81        :param offset: Number of items to skip.
82        :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
83        :param provider: Filter by provider instance ID (single string or list).
84        :param genre: Filter by genre id(s).
85        """
86        extra_query_params: dict[str, Any] = {}
87        extra_query_parts: list[str] = []
88        result = await self.get_library_items_by_query(
89            favorite=favorite,
90            search=search,
91            genre_ids=genre,
92            limit=limit,
93            offset=offset,
94            order_by=order_by,
95            provider_filter=self._ensure_provider_filter(provider),
96            extra_query_parts=extra_query_parts,
97            extra_query_params=extra_query_params,
98            in_library_only=True,
99        )
100        if search and len(result) < 25 and not offset:
101            # append author items to result
102            extra_query_parts = [
103                "WHERE audiobooks.authors LIKE :search or audiobooks.narrators LIKE :search",
104            ]
105            extra_query_params["search"] = f"%{search}%"
106            return result + await self.get_library_items_by_query(
107                favorite=favorite,
108                search=None,
109                genre_ids=genre,
110                limit=limit,
111                order_by=order_by,
112                provider_filter=self._ensure_provider_filter(provider),
113                extra_query_parts=extra_query_parts,
114                extra_query_params=extra_query_params,
115                in_library_only=True,
116            )
117        return result
118
119    async def versions(
120        self,
121        item_id: str,
122        provider_instance_id_or_domain: str,
123    ) -> UniqueList[Audiobook]:
124        """Return all versions of an audiobook we can find on all providers."""
125        audiobook = await self.get_provider_item(item_id, provider_instance_id_or_domain)
126        search_query = audiobook.name
127        result: UniqueList[Audiobook] = UniqueList()
128        for provider_id in self.mass.music.get_unique_providers():
129            provider = self.mass.get_provider(provider_id)
130            if not isinstance(provider, MusicProvider):
131                continue
132            if not provider.library_supported(MediaType.AUDIOBOOK):
133                continue
134            result.extend(
135                prov_item
136                for prov_item in await self.search(search_query, provider_id)
137                if loose_compare_strings(audiobook.name, prov_item.name)
138                # make sure that the 'base' version is NOT included
139                and not audiobook.provider_mappings.intersection(prov_item.provider_mappings)
140            )
141        return result
142
143    async def _add_library_item(self, item: Audiobook, overwrite_existing: bool = False) -> int:
144        """Add a new record to the database."""
145        db_id = await self.mass.music.database.insert(
146            self.db_table,
147            {
148                "name": item.name,
149                "sort_name": item.sort_name,
150                "version": item.version,
151                "favorite": item.favorite,
152                "metadata": serialize_to_json(item.metadata),
153                "external_ids": serialize_to_json(item.external_ids),
154                "publisher": item.publisher,
155                "authors": serialize_to_json(item.authors),
156                "narrators": serialize_to_json(item.narrators),
157                "duration": item.duration,
158                "search_name": create_safe_string(item.name, True, True),
159                "search_sort_name": create_safe_string(item.sort_name or "", True, True),
160                "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
161            },
162        )
163        # update/set provider_mappings table
164        await self.set_provider_mappings(db_id, item.provider_mappings)
165        self.logger.debug("added %s to database (id: %s)", item.name, db_id)
166        await self._set_playlog(db_id, item)
167        return db_id
168
169    async def _update_library_item(
170        self, item_id: str | int, update: Audiobook, overwrite: bool = False
171    ) -> None:
172        """Update existing record in the database."""
173        db_id = int(item_id)  # ensure integer
174        cur_item = await self.get_library_item(db_id)
175        metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
176        cur_item.external_ids.update(update.external_ids)
177        name = update.name if overwrite else cur_item.name
178        sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
179        await self.mass.music.database.update(
180            self.db_table,
181            {"item_id": db_id},
182            {
183                "name": name,
184                "sort_name": sort_name,
185                "version": update.version if overwrite else cur_item.version or update.version,
186                "metadata": serialize_to_json(metadata),
187                "external_ids": serialize_to_json(
188                    update.external_ids if overwrite else cur_item.external_ids
189                ),
190                "publisher": cur_item.publisher or update.publisher,
191                "authors": serialize_to_json(
192                    update.authors if overwrite else cur_item.authors or update.authors
193                ),
194                "narrators": serialize_to_json(
195                    update.narrators if overwrite else cur_item.narrators or update.narrators
196                ),
197                "duration": update.duration if overwrite else cur_item.duration or update.duration,
198                "search_name": create_safe_string(name, True, True),
199                "search_sort_name": create_safe_string(sort_name or "", True, True),
200                "timestamp_added": int(update.date_added.timestamp())
201                if update.date_added
202                else UNSET,
203            },
204        )
205        # update/set provider_mappings table
206        provider_mappings = (
207            update.provider_mappings
208            if overwrite
209            else {*update.provider_mappings, *cur_item.provider_mappings}
210        )
211        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
212        self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
213        await self._set_playlog(db_id, update)
214
215    async def radio_mode_base_tracks(
216        self,
217        item: Audiobook,
218        preferred_provider_instances: list[str] | None = None,
219    ) -> list[Track]:
220        """
221        Get the list of base tracks from the controller used to calculate the dynamic radio.
222
223        :param item: The Audiobook to get base tracks for.
224        :param preferred_provider_instances: List of preferred provider instance IDs to use.
225        """
226        msg = "Dynamic tracks not supported for Audiobook MediaItem"
227        raise NotImplementedError(msg)
228
229    async def match_provider(
230        self, db_audiobook: Audiobook, provider: MusicProvider, strict: bool = True
231    ) -> list[ProviderMapping]:
232        """
233        Try to find match on (streaming) provider for the provided (database) audiobook.
234
235        This is used to link objects of different providers/qualities together.
236        """
237        self.logger.debug(
238            "Trying to match audiobook %s on provider %s",
239            db_audiobook.name,
240            provider.name,
241        )
242        matches: list[ProviderMapping] = []
243        author_name = db_audiobook.authors[0] if db_audiobook.authors else ""
244        search_str = f"{author_name} - {db_audiobook.name}" if author_name else db_audiobook.name
245        search_result = await self.search(search_str, provider.instance_id)
246        for search_result_item in search_result:
247            if not search_result_item.available:
248                continue
249            if not compare_media_item(db_audiobook, search_result_item, strict=strict):
250                continue
251            # we must fetch the full audiobook version, search results can be simplified objects
252            prov_audiobook = await self.get_provider_item(
253                search_result_item.item_id,
254                search_result_item.provider,
255                fallback=search_result_item,
256            )
257            if compare_audiobook(db_audiobook, prov_audiobook, strict=strict):
258                # 100% match
259                matches.extend(prov_audiobook.provider_mappings)
260        if not matches:
261            self.logger.debug(
262                "Could not find match for Audiobook %s on provider %s",
263                db_audiobook.name,
264                provider.name,
265            )
266        return matches
267
268    async def match_providers(self, db_audiobook: Audiobook) -> None:
269        """Try to find match on all (streaming) providers for the provided (database) audiobook.
270
271        This is used to link objects of different providers/qualities together.
272        """
273        if db_audiobook.provider != "library":
274            return  # Matching only supported for database items
275
276        # try to find match on all providers
277        cur_provider_domains = {x.provider_domain for x in db_audiobook.provider_mappings}
278        for provider in self.mass.music.providers:
279            if provider.domain in cur_provider_domains:
280                continue
281            if ProviderFeature.SEARCH not in provider.supported_features:
282                continue
283            if not provider.library_supported(MediaType.AUDIOBOOK):
284                continue
285            if not provider.is_streaming_provider:
286                # matching on unique providers is pointless as they push (all) their content to MA
287                continue
288            if match := await self.match_provider(db_audiobook, provider):
289                # 100% match, we update the db with the additional provider mapping(s)
290                await self.add_provider_mappings(db_audiobook.item_id, match)
291                cur_provider_domains.add(provider.domain)
292
293    async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None:
294        """Update/set the playlog table for the given audiobook db item_id."""
295        # cleanup provider specific entries for this item
296        # we always prefer the library playlog entry
297        for prov_mapping in media_item.provider_mappings:
298            await self.mass.music.database.delete(
299                DB_TABLE_PLAYLOG,
300                {
301                    "media_type": self.media_type.value,
302                    "item_id": prov_mapping.item_id,
303                    "provider": prov_mapping.provider_instance,
304                },
305            )
306        if media_item.fully_played is None and media_item.resume_position_ms is None:
307            return
308        cur_entry = await self.mass.music.database.get_row(
309            DB_TABLE_PLAYLOG,
310            {
311                "media_type": self.media_type.value,
312                "item_id": db_id,
313                "provider": "library",
314            },
315        )
316        seconds_played = int((media_item.resume_position_ms or 0) / 1000)
317        # abort if nothing changed
318        if (
319            cur_entry
320            and parse_optional_bool(cur_entry["fully_played"]) == media_item.fully_played
321            and abs((cur_entry["seconds_played"] or 0) - seconds_played) <= 2
322        ):
323            return
324        await self.mass.music.database.insert(
325            DB_TABLE_PLAYLOG,
326            {
327                "item_id": db_id,
328                "provider": "library",
329                "media_type": media_item.media_type.value,
330                "name": media_item.name,
331                "image": serialize_to_json(media_item.image.to_dict())
332                if media_item.image
333                else None,
334                "fully_played": media_item.fully_played,
335                "seconds_played": seconds_played,
336                "timestamp": utc_timestamp(),
337            },
338            allow_replace=True,
339        )
340