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