/
/
/
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