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