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