/
/
/
1"""Audiobookshelf (abs) provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6import functools
7import itertools
8import time
9from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
10from contextlib import suppress
11from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
12
13import aioaudiobookshelf as aioabs
14from aioaudiobookshelf.client.items import LibraryItemExpandedBook as AbsLibraryItemExpandedBook
15from aioaudiobookshelf.client.items import (
16 LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast,
17)
18from aioaudiobookshelf.exceptions import LoginError as AbsLoginError
19from aioaudiobookshelf.exceptions import RefreshTokenExpiredError
20from aioaudiobookshelf.schema.author import AuthorExpanded
21from aioaudiobookshelf.schema.calls_authors import (
22 AuthorWithItemsAndSeries as AbsAuthorWithItemsAndSeries,
23)
24from aioaudiobookshelf.schema.calls_series import SeriesWithProgress as AbsSeriesWithProgress
25from aioaudiobookshelf.schema.library import (
26 LibraryItemExpanded,
27 LibraryItemExpandedBook,
28 LibraryItemExpandedPodcast,
29 LibraryItemMinifiedPodcast,
30)
31from aioaudiobookshelf.schema.library import LibraryMediaType as AbsLibraryMediaType
32from aioaudiobookshelf.schema.shelf import (
33 SeriesShelf,
34 ShelfAuthors,
35 ShelfBook,
36 ShelfEpisode,
37 ShelfLibraryItemMinified,
38 ShelfPodcast,
39 ShelfSeries,
40)
41from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId
42from aioaudiobookshelf.schema.shelf import ShelfType as AbsShelfType
43from aiohttp import web
44from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
45from music_assistant_models.enums import (
46 ConfigEntryType,
47 ContentType,
48 MediaType,
49 ProviderFeature,
50 StreamType,
51)
52from music_assistant_models.errors import LoginFailed, MediaNotFoundError
53from music_assistant_models.media_items import (
54 Audiobook,
55 AudioFormat,
56 BrowseFolder,
57 ItemMapping,
58 MediaItemType,
59 PodcastEpisode,
60 UniqueList,
61)
62from music_assistant_models.media_items.media_item import RecommendationFolder
63from music_assistant_models.streamdetails import MultiPartPath, StreamDetails
64
65from music_assistant.models.music_provider import MusicProvider
66from music_assistant.providers.audiobookshelf.parsers import (
67 parse_audiobook,
68 parse_podcast,
69 parse_podcast_episode,
70)
71
72from .constants import (
73 ABS_BROWSE_ITEMS_TO_PATH,
74 ABS_SHELF_ID_ICONS,
75 ABS_SHELF_ID_TRANSLATION_KEY,
76 AIOHTTP_TIMEOUT,
77 CACHE_CATEGORY_LIBRARIES,
78 CACHE_KEY_LIBRARIES,
79 CONF_API_TOKEN,
80 CONF_HIDE_EMPTY_PODCASTS,
81 CONF_OLD_TOKEN,
82 CONF_PASSWORD,
83 CONF_URL,
84 CONF_USERNAME,
85 CONF_VERIFY_SSL,
86 AbsBrowseItemsBookTranslationKey,
87 AbsBrowseItemsPodcastTranslationKey,
88 AbsBrowsePaths,
89)
90from .helpers import LibrariesHelper, LibraryHelper, ProgressGuard
91
92if TYPE_CHECKING:
93 from aioaudiobookshelf.schema.events_socket import LibraryItemRemoved
94 from aioaudiobookshelf.schema.media_progress import MediaProgress
95 from aioaudiobookshelf.schema.user import User
96 from music_assistant_models.media_items import Podcast
97 from music_assistant_models.provider import ProviderManifest
98
99 from music_assistant.mass import MusicAssistant
100 from music_assistant.models import ProviderInstanceType
101
102SUPPORTED_FEATURES = {
103 ProviderFeature.LIBRARY_PODCASTS,
104 ProviderFeature.LIBRARY_AUDIOBOOKS,
105 ProviderFeature.BROWSE,
106 ProviderFeature.RECOMMENDATIONS,
107}
108
109
110async def setup(
111 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
112) -> ProviderInstanceType:
113 """Initialize provider(instance) with given configuration."""
114 return Audiobookshelf(mass, manifest, config, SUPPORTED_FEATURES)
115
116
117async def get_config_entries(
118 mass: MusicAssistant,
119 instance_id: str | None = None,
120 action: str | None = None,
121 values: dict[str, ConfigValueType] | None = None,
122) -> tuple[ConfigEntry, ...]:
123 """
124 Return Config entries to setup this provider.
125
126 instance_id: id of an existing provider instance (None if new instance setup).
127 action: [optional] action key called from config entries UI.
128 values: the (intermediate) raw values for config entries sent with the action.
129 """
130 # ruff: noqa: ARG001
131 return (
132 ConfigEntry(
133 key="label",
134 type=ConfigEntryType.LABEL,
135 label="Please provide the address of your Audiobookshelf instance. To authenticate "
136 "you have two options: "
137 "a) Provide username AND password. Leave the API key empty. "
138 "b) Provide ONLY an API key.",
139 ),
140 ConfigEntry(
141 key=CONF_URL,
142 type=ConfigEntryType.STRING,
143 label="Server",
144 required=True,
145 description="The URL of the Audiobookshelf server to connect to. For example "
146 "https://abs.domain.tld/ or http://192.168.1.4:13378/",
147 ),
148 ConfigEntry(
149 key=CONF_USERNAME,
150 type=ConfigEntryType.STRING,
151 label="Username",
152 required=False,
153 description="The username to authenticate to the remote server.",
154 ),
155 ConfigEntry(
156 key=CONF_PASSWORD,
157 type=ConfigEntryType.SECURE_STRING,
158 label="Password",
159 required=False,
160 description="The password to authenticate to the remote server.",
161 ),
162 ConfigEntry(
163 key=CONF_API_TOKEN,
164 type=ConfigEntryType.SECURE_STRING,
165 label="API key _instead_ of user/ password. (ABS version >= 2.26)",
166 required=False,
167 description="Instead of using a username and password, "
168 "you may provide an API key (ABS version >= 2.26). "
169 "Please consult the docs.",
170 ),
171 ConfigEntry(
172 key=CONF_OLD_TOKEN,
173 type=ConfigEntryType.SECURE_STRING,
174 label="old token",
175 required=False,
176 hidden=True,
177 ),
178 ConfigEntry(
179 key=CONF_VERIFY_SSL,
180 type=ConfigEntryType.BOOLEAN,
181 label="Verify SSL",
182 required=False,
183 description="Whether or not to verify the certificate of SSL/TLS connections.",
184 advanced=True,
185 default_value=True,
186 ),
187 ConfigEntry(
188 key=CONF_HIDE_EMPTY_PODCASTS,
189 type=ConfigEntryType.BOOLEAN,
190 label="Hide empty podcasts.",
191 required=False,
192 description="This will skip podcasts with no episodes associated.",
193 advanced=True,
194 default_value=False,
195 ),
196 )
197
198
199R = TypeVar("R")
200P = ParamSpec("P")
201
202
203class Audiobookshelf(MusicProvider):
204 """Audiobookshelf MusicProvider."""
205
206 _on_unload_callbacks: list[Callable[[], None]]
207
208 @staticmethod
209 def handle_refresh_token(
210 method: Callable[P, Coroutine[Any, Any, R]],
211 ) -> Callable[P, Coroutine[Any, Any, R]]:
212 """Decorate a method to handle an expired refresh token by relogin."""
213
214 @functools.wraps(method)
215 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
216 self = cast("Audiobookshelf", args[0])
217 try:
218 return await method(*args, **kwargs)
219 except RefreshTokenExpiredError:
220 self.logger.debug("Refresh token expired. Trying to renew.")
221 await self.reauthenticate()
222 return await method(*args, **kwargs)
223
224 return wrapper
225
226 async def handle_async_init(self) -> None:
227 """Pass config values to client and initialize."""
228 self._on_unload_callbacks: list[Callable[[], None]] = []
229 base_url = str(self.config.get_value(CONF_URL))
230 username = str(self.config.get_value(CONF_USERNAME))
231 password = str(self.config.get_value(CONF_PASSWORD))
232 token_old = self.config.get_value(CONF_OLD_TOKEN)
233 token_api = self.config.get_value(CONF_API_TOKEN)
234 verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL))
235 session_config = aioabs.SessionConfiguration(
236 session=self.mass.http_session,
237 url=base_url,
238 verify_ssl=verify_ssl,
239 logger=self.logger,
240 pagination_items_per_page=30, # audible provider goes with 50 for pagination
241 timeout=AIOHTTP_TIMEOUT,
242 )
243 # If we are configured with a non-expiring API key or not.
244 self.is_token_user = False
245 try:
246 if token_api is not None or token_old is not None:
247 _token = token_api if token_api is not None else token_old
248 session_config.token = str(_token)
249 (
250 self._client,
251 self._client_socket,
252 ) = await aioabs.get_user_and_socket_client_by_token(session_config=session_config)
253 self.is_token_user = True
254 else:
255 self._client, self._client_socket = await aioabs.get_user_and_socket_client(
256 session_config=session_config, username=username, password=password
257 )
258 await self._client_socket.init_client()
259 except AbsLoginError as exc:
260 raise LoginFailed(f"Login to abs instance at {base_url} failed.") from exc
261
262 if token_old is not None and token_api is None:
263 # Log Message that the old token won't work
264 _version = self._client.server_settings.version.split(".")
265 if len(_version) >= 2:
266 try:
267 major, minor = int(_version[0]), int(_version[1])
268 except ValueError:
269 major = minor = 0
270 if major >= 2 and minor >= 26:
271 self.logger.warning(
272 """
273
274######## Audiobookshelf API key change #############################################################
275
276Audiobookshelf introduced a new API key system in version 2.26 (JWT).
277You are still using a token configured with a previous version of Audiobookshelf,
278but you are running version %s. This will stop working in a future Audiobookshelf release.
279Please create a non-expiring API Key instead, and update your configuration accordingly.
280Refer to the documentation of Audiobookshelf, https://www.audiobookshelf.org/guides/api-keys/
281and of Music Assistant https://www.music-assistant.io/music-providers/audiobookshelf/
282for more details.
283
284""",
285 self._client.server_settings.version,
286 )
287
288 cached_libraries = await self.mass.cache.get(
289 key=CACHE_KEY_LIBRARIES,
290 provider=self.instance_id,
291 category=CACHE_CATEGORY_LIBRARIES,
292 default=None,
293 )
294 if cached_libraries is None:
295 self.libraries = LibrariesHelper()
296 # We need the library ids for recommendations. If the cache got cleared e.g. by a db
297 # migration, we might end up with empty library helpers on a configured provider. Note,
298 # that the lib item ids are not synced, still only on full provider sync, instead the
299 # sets are empty. Full sync is expensive.
300 # See warning in browse_lib_podcasts / _browse_books
301 libraries = await self._client.get_all_libraries()
302 for library in libraries:
303 if library.media_type == AbsLibraryMediaType.BOOK:
304 self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
305 elif library.media_type == AbsLibraryMediaType.PODCAST:
306 self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
307 else:
308 self.libraries = LibrariesHelper.from_dict(cached_libraries)
309
310 # set socket callbacks
311 self._client_socket.set_item_callbacks(
312 on_item_added=self._socket_abs_item_changed,
313 on_item_updated=self._socket_abs_item_changed,
314 on_item_removed=self._socket_abs_item_removed,
315 on_items_added=self._socket_abs_item_changed,
316 on_items_updated=self._socket_abs_item_changed,
317 )
318
319 self._client_socket.set_user_callbacks(
320 on_user_item_progress_updated=self._socket_abs_user_item_progress_updated,
321 )
322
323 self._client_socket.set_refresh_token_expired_callback(
324 on_refresh_token_expired=self._socket_abs_refresh_token_expired
325 )
326
327 # progress guard
328 self.progress_guard = ProgressGuard()
329
330 # safe guard reauthentication
331 self.reauthenticate_lock = asyncio.Lock()
332 self.reauthenticate_last = 0.0
333
334 # register dynamic stream route for audiobook parts
335 self._on_unload_callbacks.append(
336 self.mass.streams.register_dynamic_route(
337 f"/{self.instance_id}_part_stream", self._handle_audiobook_part_request
338 )
339 )
340 self._on_unload_callbacks.append(
341 self.mass.streams.register_dynamic_route(
342 f"/{self.instance_id}_episode_stream", self._handle_episode_request
343 )
344 )
345
346 @handle_refresh_token
347 async def unload(self, is_removed: bool = False) -> None:
348 """
349 Handle unload/close of the provider.
350
351 Called when provider is deregistered (e.g. MA exiting or config reloading).
352 is_removed will be set to True when the provider is removed from the configuration.
353 """
354 await self._client.logout()
355 await self._client_socket.logout()
356 for callback in self._on_unload_callbacks:
357 callback()
358
359 @property
360 def is_streaming_provider(self) -> bool:
361 """Return True if the provider is a streaming provider."""
362 # For streaming providers return True here but for local file based providers return False.
363 return False
364
365 @handle_refresh_token
366 async def sync_library(self, media_type: MediaType) -> None:
367 """Obtain audiobook library ids and podcast library ids."""
368 libraries = await self._client.get_all_libraries()
369 if len(libraries) == 0:
370 self._log_no_libraries()
371 for library in libraries:
372 if library.media_type == AbsLibraryMediaType.BOOK and media_type == MediaType.AUDIOBOOK:
373 self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
374 elif (
375 library.media_type == AbsLibraryMediaType.PODCAST
376 and media_type == MediaType.PODCAST
377 ):
378 self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
379 await super().sync_library(media_type)
380 await self._cache_set_helper_libraries()
381
382 # update playlog
383 user = await self._client.get_my_user()
384 await self._set_playlog_from_user(user)
385
386 async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
387 """Retrieve library/subscribed podcasts from the provider.
388
389 Minified podcast information is enough.
390 """
391 for pod_lib_id in self.libraries.podcasts:
392 async for response in self._client.get_library_items(library_id=pod_lib_id):
393 if not response.results:
394 break
395 podcast_ids = [x.id_ for x in response.results]
396 # store uuids
397 self.libraries.podcasts[pod_lib_id].item_ids.update(podcast_ids)
398 for podcast_minified in response.results:
399 assert isinstance(podcast_minified, LibraryItemMinifiedPodcast)
400 mass_podcast = parse_podcast(
401 abs_podcast=podcast_minified,
402 instance_id=self.instance_id,
403 domain=self.domain,
404 token=self._client.token,
405 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
406 )
407 if (
408 bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
409 and mass_podcast.total_episodes == 0
410 ):
411 continue
412 yield mass_podcast
413
414 @handle_refresh_token
415 async def _get_abs_expanded_podcast(
416 self, prov_podcast_id: str
417 ) -> AbsLibraryItemExpandedPodcast:
418 abs_podcast = await self._client.get_library_item_podcast(
419 podcast_id=prov_podcast_id, expanded=True
420 )
421 assert isinstance(abs_podcast, AbsLibraryItemExpandedPodcast)
422
423 return abs_podcast
424
425 @handle_refresh_token
426 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
427 """Get single podcast."""
428 abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
429 return parse_podcast(
430 abs_podcast=abs_podcast,
431 instance_id=self.instance_id,
432 domain=self.domain,
433 token=self._client.token,
434 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
435 )
436
437 async def get_podcast_episodes(
438 self, prov_podcast_id: str
439 ) -> AsyncGenerator[PodcastEpisode, None]:
440 """Get all podcast episodes of podcast.
441
442 Adds progress information.
443 """
444 abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
445 episode_cnt = 1
446 # the user has the progress of all media items
447 # so we use a single api call here to obtain possibly many
448 # progresses for episodes
449 user = await self._client.get_my_user()
450 abs_progresses = {
451 x.episode_id: x
452 for x in user.media_progress
453 if x.episode_id is not None and x.library_item_id == prov_podcast_id
454 }
455 for abs_episode in abs_podcast.media.episodes:
456 progress = abs_progresses.get(abs_episode.id_, None)
457 mass_episode = parse_podcast_episode(
458 episode=abs_episode,
459 prov_podcast_id=prov_podcast_id,
460 fallback_episode_cnt=episode_cnt,
461 instance_id=self.instance_id,
462 domain=self.domain,
463 token=self._client.token,
464 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
465 media_progress=progress,
466 )
467 yield mass_episode
468 episode_cnt += 1
469
470 @handle_refresh_token
471 async def get_podcast_episode(
472 self, prov_episode_id: str, add_progress: bool = True
473 ) -> PodcastEpisode:
474 """Get single podcast episode."""
475 prov_podcast_id, e_id = prov_episode_id.split(" ")
476 abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=prov_podcast_id)
477 episode_cnt = 1
478 for abs_episode in abs_podcast.media.episodes:
479 if abs_episode.id_ == e_id:
480 progress = None
481 if add_progress:
482 progress = await self._client.get_my_media_progress(
483 item_id=prov_podcast_id, episode_id=abs_episode.id_
484 )
485 return parse_podcast_episode(
486 episode=abs_episode,
487 prov_podcast_id=prov_podcast_id,
488 fallback_episode_cnt=episode_cnt,
489 instance_id=self.instance_id,
490 domain=self.domain,
491 token=self._client.token,
492 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
493 media_progress=progress,
494 )
495
496 episode_cnt += 1
497 raise MediaNotFoundError("Episode not found")
498
499 async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]:
500 """Get Audiobook libraries.
501
502 Need expanded version for chapters.
503 """
504 for book_lib_id in self.libraries.audiobooks:
505 async for response in self._client.get_library_items(library_id=book_lib_id):
506 if not response.results:
507 break
508 book_ids = [x.id_ for x in response.results]
509 # store uuids
510 self.libraries.audiobooks[book_lib_id].item_ids.update(book_ids)
511 # use expanded version for chapters/ caching.
512 books_expanded = await self._client.get_library_item_batch_book(item_ids=book_ids)
513 for book_expanded in books_expanded:
514 # If the book has no audiofiles, we skip -> ebook only.
515 if len(book_expanded.media.tracks) == 0:
516 continue
517 mass_audiobook = parse_audiobook(
518 abs_audiobook=book_expanded,
519 instance_id=self.instance_id,
520 domain=self.domain,
521 token=self._client.token,
522 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
523 )
524 yield mass_audiobook
525
526 @handle_refresh_token
527 async def _get_abs_expanded_audiobook(
528 self, prov_audiobook_id: str
529 ) -> AbsLibraryItemExpandedBook:
530 abs_audiobook = await self._client.get_library_item_book(
531 book_id=prov_audiobook_id, expanded=True
532 )
533 assert isinstance(abs_audiobook, AbsLibraryItemExpandedBook)
534
535 return abs_audiobook
536
537 @handle_refresh_token
538 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
539 """Get a single audiobook.
540
541 Progress is added here.
542 """
543 progress = await self._client.get_my_media_progress(item_id=prov_audiobook_id)
544 abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=prov_audiobook_id)
545 return parse_audiobook(
546 abs_audiobook=abs_audiobook,
547 instance_id=self.instance_id,
548 domain=self.domain,
549 token=self._client.token,
550 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
551 media_progress=progress,
552 )
553
554 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
555 """Get stream of item."""
556 if media_type == MediaType.PODCAST_EPISODE:
557 return await self._get_stream_details_episode(item_id)
558 if media_type == MediaType.AUDIOBOOK:
559 abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=item_id)
560 return await self._get_stream_details_audiobook(abs_audiobook)
561 raise MediaNotFoundError("Stream unknown")
562
563 async def _get_stream_details_audiobook(
564 self, abs_audiobook: AbsLibraryItemExpandedBook
565 ) -> StreamDetails:
566 """Streamdetails audiobook.
567
568 We always use a custom stream type, also for single file, such
569 that we can handle an ffmpeg error and refresh our tokens.
570 """
571 tracks = abs_audiobook.media.tracks
572 if len(tracks) == 0:
573 raise MediaNotFoundError("Stream not found")
574
575 content_type = ContentType.UNKNOWN
576 if abs_audiobook.media.tracks[0].metadata is not None:
577 content_type = ContentType.try_parse(abs_audiobook.media.tracks[0].metadata.ext)
578
579 file_parts: list[MultiPartPath] = []
580 abs_base_url = str(self.config.get_value(CONF_URL))
581 if self.is_token_user:
582 self.logger.debug("Token User - Streams are direct.")
583 for idx, track in enumerate(tracks):
584 if self.is_token_user:
585 # an api key is long-lived
586 stream_url = f"{abs_base_url}{track.content_url}?token={self._client.token}"
587 else:
588 # to ensure token is always valid, we create a dynamic url
589 # this ensures that we always get a fresh token on each part
590 # without having to deal with a custom stream etc.
591 # we also use this for the first part, otherwise we can't seek
592 stream_url = (
593 f"{self.mass.streams.base_url}/{self.instance_id}_part_stream?"
594 f"audiobook_id={abs_audiobook.id_}&part_id={idx}"
595 )
596 file_parts.append(MultiPartPath(path=stream_url, duration=track.duration))
597
598 return StreamDetails(
599 provider=self.instance_id,
600 item_id=abs_audiobook.id_,
601 audio_format=AudioFormat(content_type=content_type),
602 media_type=MediaType.AUDIOBOOK,
603 stream_type=StreamType.HTTP,
604 duration=int(abs_audiobook.media.duration),
605 path=file_parts,
606 can_seek=True,
607 allow_seek=True,
608 )
609
610 async def _get_stream_details_episode(self, podcast_id: str) -> StreamDetails:
611 """Streamdetails of a podcast episode.
612
613 There are no multi-file podcasts in abs, but we use a custom
614 stream to handle possible ffmpeg errors.
615 """
616 abs_podcast_id, abs_episode_id = podcast_id.split(" ")
617 abs_episode = None
618
619 abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=abs_podcast_id)
620 for abs_episode in abs_podcast.media.episodes:
621 if abs_episode.id_ == abs_episode_id:
622 break
623 if abs_episode is None:
624 raise MediaNotFoundError("Stream not found")
625 content_type = ContentType.UNKNOWN
626 if abs_episode.audio_track.metadata is not None:
627 content_type = ContentType.try_parse(abs_episode.audio_track.metadata.ext)
628
629 if self.is_token_user:
630 self.logger.debug("Token User - Stream is direct.")
631 # long lived API token, no need for detour
632 abs_base_url = str(self.config.get_value(CONF_URL))
633 stream_url = (
634 f"{abs_base_url}{abs_episode.audio_track.content_url}?token={self._client.token}"
635 )
636 else:
637 stream_url = (
638 f"{self.mass.streams.base_url}/{self.instance_id}_episode_stream?"
639 f"podcast_id={abs_podcast.id_}&episode_id={abs_episode.id_}"
640 )
641
642 return StreamDetails(
643 provider=self.instance_id,
644 item_id=podcast_id,
645 audio_format=AudioFormat(
646 content_type=content_type,
647 ),
648 media_type=MediaType.PODCAST_EPISODE,
649 stream_type=StreamType.HTTP,
650 can_seek=True,
651 allow_seek=True,
652 path=stream_url,
653 )
654
655 async def _handle_audiobook_part_request(self, request: web.Request) -> web.Response:
656 """
657 Handle dynamic audiobook part stream request.
658
659 We redirect to the actual stream url with token.
660 This is done because the token might expire, so we need to
661 generate a fresh url on each part.
662 """
663 if not (audiobook_id := request.query.get("audiobook_id")):
664 return web.Response(status=400, text="Missing audiobook_id")
665 if not (part_id := request.query.get("part_id")):
666 return web.Response(status=400, text="Missing part_id")
667 abs_audiobook = await self._get_abs_expanded_audiobook(prov_audiobook_id=audiobook_id)
668 part_id = int(part_id) # type: ignore[assignment]
669 try:
670 part_track = abs_audiobook.media.tracks[part_id]
671 except IndexError:
672 return web.Response(status=404, text="Part not found")
673
674 base_url = str(self.config.get_value(CONF_URL))
675 stream_url = f"{base_url}{part_track.content_url}?token={self._client.token}"
676 # redirect to the actual stream url
677 raise web.HTTPFound(location=stream_url)
678
679 async def _handle_episode_request(self, request: web.Request) -> web.Response:
680 """Podcast episode request.
681
682 For a podcast episode, we only have a single file, but the token might be expired should
683 user try to seek an episode.
684 """
685 if not (abs_podcast_id := request.query.get("podcast_id")):
686 return web.Response(status=400, text="Missing podcast_id")
687 if not (abs_episode_id := request.query.get("episode_id")):
688 return web.Response(status=400, text="Missing episode_id")
689 abs_podcast = await self._get_abs_expanded_podcast(prov_podcast_id=abs_podcast_id)
690 abs_episode = None
691 for abs_episode in abs_podcast.media.episodes:
692 if abs_episode.id_ == abs_episode_id:
693 break
694 if abs_episode is None:
695 return web.Response(status=400, text="Stream not found")
696
697 base_url = str(self.config.get_value(CONF_URL))
698 stream_url = f"{base_url}{abs_episode.audio_track.content_url}?token={self._client.token}"
699
700 # redirect to the actual stream url
701 raise web.HTTPFound(location=stream_url)
702
703 @handle_refresh_token
704 async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
705 """Return finished:bool, position_ms: int."""
706 progress: None | MediaProgress = None
707 if media_type == MediaType.PODCAST_EPISODE:
708 abs_podcast_id, abs_episode_id = item_id.split(" ")
709 progress = await self._client.get_my_media_progress(
710 item_id=abs_podcast_id, episode_id=abs_episode_id
711 )
712
713 if media_type == MediaType.AUDIOBOOK:
714 progress = await self._client.get_my_media_progress(item_id=item_id)
715
716 if progress is not None and progress.current_time is not None:
717 self.logger.debug("Resume position: obtained.")
718 return progress.is_finished, int(progress.current_time * 1000)
719
720 return False, 0
721
722 @handle_refresh_token
723 async def recommendations(self) -> list[RecommendationFolder]:
724 """Get recommendations."""
725 # We have to avoid "flooding" the home page, which becomes especially troublesome if users
726 # have multiple libraries. Instead we collect per ShelfId, and make sure, that we always get
727 # roughly the same amount of items per row, no matter the amount of libraries
728 # List of list (one list per lib) here, such that we can pick the items per lib later.
729 items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType | BrowseFolder]]] = {}
730
731 all_libraries = {**self.libraries.audiobooks, **self.libraries.podcasts}
732 max_items_per_row = 20
733 num_libraries = len(all_libraries)
734
735 if num_libraries == 0:
736 self._log_no_libraries()
737 return []
738
739 limit_items_per_lib = max_items_per_row // num_libraries
740 limit_items_per_lib = 1 if limit_items_per_lib == 0 else limit_items_per_lib
741
742 for library_id in all_libraries:
743 shelves = await self._client.get_library_personalized_view(
744 library_id=library_id, limit=limit_items_per_lib
745 )
746 await self._recommendations_iter_shelves(shelves, library_id, items_by_shelf_id)
747
748 folders: list[RecommendationFolder] = []
749 for shelf_id, item_lists in items_by_shelf_id.items():
750 # we have something like [[A, B], [C, D, E], [F]]
751 # and want [A, C, F, B, D, E]
752 recommendation_items = [
753 x
754 for x in itertools.chain.from_iterable(itertools.zip_longest(*item_lists))
755 if x is not None
756 ][:max_items_per_row]
757
758 # shelf ids follow pattern:
759 # recently-added
760 # newest-episodes
761 # etc
762 name = f"{shelf_id.capitalize().replace('-', ' ')}"
763 if ABS_SHELF_ID_TRANSLATION_KEY.get(shelf_id):
764 name = "" # use translation key if available
765 folders.append(
766 RecommendationFolder(
767 item_id=f"{shelf_id}",
768 name=name,
769 icon=ABS_SHELF_ID_ICONS.get(shelf_id),
770 translation_key=ABS_SHELF_ID_TRANSLATION_KEY.get(shelf_id),
771 items=UniqueList(recommendation_items),
772 provider=self.instance_id,
773 )
774 )
775
776 # Browse "recommendation" for convenience. If the user has
777 # multiple audiobook libraries, we return a listing of them.
778 # If there is only a single audiobook library, we add the folders
779 # from _browse_lib_audiobooks, i.e. Authors, Narrators etc.
780 # Podcast libs do not have filter folders, so always the root folders.
781 browse_items: list[MediaItemType | BrowseFolder] = []
782 translation_key = "libraries"
783 if len(self.libraries.audiobooks) <= 1:
784 if len(self.libraries.podcasts) == 0:
785 translation_key = "library"
786
787 # audiobooklibs are first, and we have at max 1 audiobook lib
788 _browse_root = self._browse_root(append_mediatype_suffix=False)
789 if len(self.libraries.audiobooks) == 0:
790 browse_items.extend(_browse_root)
791 else:
792 assert isinstance(_browse_root[0], BrowseFolder)
793 _path = _browse_root[0].path
794 browse_items.extend(self._browse_lib_audiobooks(current_path=_path))
795 # add podcast roots
796 browse_items.extend(_browse_root[1:])
797 else:
798 browse_items = list(self._browse_root())
799
800 folders.append(
801 RecommendationFolder(
802 item_id="browse",
803 name="", # use translation key
804 icon="mdi-bookshelf",
805 translation_key=translation_key,
806 items=UniqueList(browse_items),
807 provider=self.instance_id,
808 )
809 )
810
811 return folders
812
813 async def _recommendations_iter_shelves(
814 self,
815 shelves: list[ShelfBook | ShelfPodcast | ShelfAuthors | ShelfEpisode | ShelfSeries],
816 library_id: str,
817 items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType | BrowseFolder]]],
818 ) -> None:
819 for shelf in shelves:
820 media_type: MediaType
821 match shelf.type_:
822 case AbsShelfType.PODCAST:
823 media_type = MediaType.PODCAST
824 case AbsShelfType.EPISODE:
825 media_type = MediaType.PODCAST_EPISODE
826 case AbsShelfType.BOOK:
827 media_type = MediaType.AUDIOBOOK
828 case AbsShelfType.SERIES | AbsShelfType.AUTHORS:
829 media_type = MediaType.FOLDER
830 case _:
831 # this would be authors, currently
832 continue
833
834 items: list[MediaItemType | BrowseFolder] = []
835 # Recently added is the _only_ case, where we get a full podcast
836 # We have a podcast object with only the episodes matching the
837 # shelf.id_ otherwise.
838 match shelf.id_:
839 case (
840 AbsShelfId.RECENTLY_ADDED
841 | AbsShelfId.LISTEN_AGAIN
842 | AbsShelfId.DISCOVER
843 | AbsShelfId.NEWEST_EPISODES
844 | AbsShelfId.CONTINUE_LISTENING
845 ):
846 for entity in shelf.entities:
847 assert isinstance(entity, ShelfLibraryItemMinified)
848 item: MediaItemType | None = None
849 if media_type in [MediaType.PODCAST, MediaType.AUDIOBOOK]:
850 item = await self.mass.music.get_library_item_by_prov_id(
851 media_type=media_type,
852 provider_instance_id_or_domain=self.instance_id,
853 item_id=entity.id_,
854 )
855 elif media_type == MediaType.PODCAST_EPISODE:
856 podcast_id = entity.id_
857 if entity.recent_episode is None:
858 continue
859 # we only have a PodcastEpisode here, with limited information
860 item = parse_podcast_episode(
861 episode=entity.recent_episode,
862 prov_podcast_id=podcast_id,
863 instance_id=self.instance_id,
864 domain=self.domain,
865 token=self._client.token,
866 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
867 )
868 if item is not None:
869 items.append(item)
870 case AbsShelfId.RECENT_SERIES | AbsShelfId.CONTINUE_SERIES:
871 # We jump into a browse folder here if we have SeriesShelf, set path up as if
872 # browse function used.
873 if isinstance(shelf, ShelfSeries):
874 for entity in shelf.entities:
875 assert isinstance(entity, SeriesShelf)
876 if len(entity.books) == 0:
877 continue
878 path = (
879 f"{self.instance_id}://"
880 f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/"
881 f"{AbsBrowsePaths.SERIES}/{entity.id_}"
882 )
883 items.append(
884 BrowseFolder(
885 item_id=entity.id_,
886 name=entity.name,
887 provider=self.instance_id,
888 path=path,
889 )
890 )
891 elif isinstance(shelf, ShelfBook) and media_type == MediaType.AUDIOBOOK:
892 # Single books, must be audiobooks
893 for entity in shelf.entities:
894 item = await self.mass.music.get_library_item_by_prov_id(
895 media_type=media_type,
896 provider_instance_id_or_domain=self.instance_id,
897 item_id=entity.id_,
898 )
899 if item is not None:
900 items.append(item)
901 case AbsShelfId.NEWEST_AUTHORS:
902 # same as for series, use a folder
903 for entity in shelf.entities:
904 assert isinstance(entity, AuthorExpanded)
905 if entity.num_books == 0:
906 continue
907 path = (
908 f"{self.instance_id}://"
909 f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/"
910 f"{AbsBrowsePaths.AUTHORS}/{entity.id_}"
911 )
912 items.append(
913 BrowseFolder(
914 item_id=entity.id_,
915 name=entity.name,
916 provider=self.instance_id,
917 path=path,
918 )
919 )
920 if not items:
921 continue
922
923 # add collected items
924 assert isinstance(shelf.id_, AbsShelfId)
925 items_collected = items_by_shelf_id.get(shelf.id_, [])
926 items_collected.append(items)
927 items_by_shelf_id[shelf.id_] = items_collected
928
929 @handle_refresh_token
930 async def on_played(
931 self,
932 media_type: MediaType,
933 prov_item_id: str,
934 fully_played: bool,
935 position: int,
936 media_item: MediaItemType,
937 is_playing: bool = False,
938 ) -> None:
939 """Update progress in Audiobookshelf.
940
941 In our case media_type may have 3 values:
942 - PODCAST
943 - PODCAST_EPISODE
944 - AUDIOBOOK
945 We ignore PODCAST (function is called on adding a podcast with position=None)
946
947 """
948 if media_type == MediaType.PODCAST_EPISODE:
949 abs_podcast_id, abs_episode_id = prov_item_id.split(" ")
950
951 # guard, see progress guard class docstrings for explanation
952 if not self.progress_guard.guard_ok_mass(
953 item_id=abs_podcast_id, episode_id=abs_episode_id
954 ):
955 return
956 self.progress_guard.add_progress(item_id=abs_podcast_id, episode_id=abs_episode_id)
957
958 if media_item is None or not isinstance(media_item, PodcastEpisode):
959 return
960
961 if position == 0 and not fully_played:
962 # marked unplayed
963 mp = await self._client.get_my_media_progress(
964 item_id=abs_podcast_id, episode_id=abs_episode_id
965 )
966 if mp is not None:
967 await self._client.remove_my_media_progress(media_progress_id=mp.id_)
968 self.logger.debug(f"Removed media progress of {media_type.value}.")
969 return
970
971 duration = media_item.duration
972 self.logger.debug(
973 f"Updating media progress of {media_type.value}, title {media_item.name}."
974 )
975 await self._client.update_my_media_progress(
976 item_id=abs_podcast_id,
977 episode_id=abs_episode_id,
978 duration_seconds=duration,
979 progress_seconds=position,
980 is_finished=fully_played,
981 )
982
983 if media_type == MediaType.AUDIOBOOK:
984 # guard, see progress guard class docstrings for explanation
985 if not self.progress_guard.guard_ok_mass(item_id=prov_item_id):
986 return
987 self.progress_guard.add_progress(item_id=prov_item_id)
988
989 if media_item is None or not isinstance(media_item, Audiobook):
990 return
991
992 if position == 0 and not fully_played:
993 # marked unplayed
994 mp = await self._client.get_my_media_progress(item_id=prov_item_id)
995 if mp is not None:
996 await self._client.remove_my_media_progress(media_progress_id=mp.id_)
997 self.logger.debug(f"Removed media progress of {media_type.value}.")
998 return
999
1000 duration = media_item.duration
1001 self.logger.debug(f"Updating {media_type.value} named {media_item.name} progress")
1002 await self._client.update_my_media_progress(
1003 item_id=prov_item_id,
1004 duration_seconds=duration,
1005 progress_seconds=position,
1006 is_finished=fully_played,
1007 )
1008
1009 @handle_refresh_token
1010 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
1011 """Browse for audiobookshelf.
1012
1013 Generates this view:
1014 Library_Name_A (Audiobooks)
1015 Audiobooks
1016 Audiobook_1
1017 Audiobook_2
1018 Series
1019 Series_1
1020 Audiobook_1
1021 Audiobook_2
1022 Series_2
1023 Audiobook_3
1024 Audiobook_4
1025 Collections
1026 Collection_1
1027 Audiobook_1
1028 Audiobook_2
1029 Collection_2
1030 Audiobook_3
1031 Audiobook_4
1032 Authors
1033 Author_1
1034 Series_1
1035 Audiobook_1
1036 Audiobook_2
1037 Author_2
1038 Audiobook_3
1039 Library_Name_B (Podcasts)
1040 Podcast_1
1041 Podcast_2
1042 """
1043 item_path = path.split("://", 1)[1]
1044 if not item_path:
1045 return self._browse_root()
1046 sub_path = item_path.split("/")
1047 lib_key, lib_id = sub_path[0].split(" ")
1048 if len(sub_path) == 1:
1049 if lib_key == AbsBrowsePaths.LIBRARIES_PODCAST:
1050 return await self._browse_lib_podcasts(library_id=lib_id)
1051 return self._browse_lib_audiobooks(current_path=path)
1052 if len(sub_path) == 2:
1053 item_key = sub_path[1]
1054 match item_key:
1055 case AbsBrowsePaths.AUTHORS:
1056 return await self._browse_authors(current_path=path, library_id=lib_id)
1057 case AbsBrowsePaths.NARRATORS:
1058 return await self._browse_narrators(current_path=path, library_id=lib_id)
1059 case AbsBrowsePaths.SERIES:
1060 return await self._browse_series(current_path=path, library_id=lib_id)
1061 case AbsBrowsePaths.COLLECTIONS:
1062 return await self._browse_collections(current_path=path, library_id=lib_id)
1063 case AbsBrowsePaths.AUDIOBOOKS:
1064 return await self._browse_books(library_id=lib_id)
1065 elif len(sub_path) == 3:
1066 item_key, item_id = sub_path[1:3]
1067 match item_key:
1068 case AbsBrowsePaths.AUTHORS:
1069 return await self._browse_author_books(current_path=path, author_id=item_id)
1070 case AbsBrowsePaths.NARRATORS:
1071 return await self._browse_narrator_books(
1072 library_id=lib_id, narrator_filter_str=item_id
1073 )
1074 case AbsBrowsePaths.SERIES:
1075 return await self._browse_series_books(series_id=item_id)
1076 case AbsBrowsePaths.COLLECTIONS:
1077 return await self._browse_collection_books(collection_id=item_id)
1078 elif len(sub_path) == 4:
1079 # series within author
1080 series_id = sub_path[3]
1081 return await self._browse_series_books(series_id=series_id)
1082 return []
1083
1084 def _browse_root(self, append_mediatype_suffix: bool = True) -> Sequence[BrowseFolder]:
1085 items = []
1086
1087 def _get_folder(
1088 path: str, lib_id: str, lib_name: str, translation_key: str | None = None
1089 ) -> BrowseFolder:
1090 return BrowseFolder(
1091 item_id=lib_id,
1092 name=lib_name,
1093 translation_key=translation_key, # if given, <name>: <translation> in frontend
1094 provider=self.instance_id,
1095 path=f"{self.instance_id}://{path}",
1096 )
1097
1098 if len(self.libraries.audiobooks) == 0 and len(self.libraries.podcasts) == 0:
1099 self._log_no_libraries()
1100 return []
1101
1102 translation_key: str | None
1103 for lib_id, lib in self.libraries.audiobooks.items():
1104 path = f"{AbsBrowsePaths.LIBRARIES_BOOK} {lib_id}"
1105 translation_key = None
1106 if append_mediatype_suffix:
1107 translation_key = AbsBrowseItemsBookTranslationKey.AUDIOBOOKS
1108 items.append(
1109 _get_folder(path, lib_id, lib_name=lib.name, translation_key=translation_key)
1110 )
1111 for lib_id, lib in self.libraries.podcasts.items():
1112 path = f"{AbsBrowsePaths.LIBRARIES_PODCAST} {lib_id}"
1113 translation_key = None
1114 if append_mediatype_suffix:
1115 translation_key = AbsBrowseItemsPodcastTranslationKey.PODCASTS
1116 items.append(
1117 _get_folder(path, lib_id, lib_name=lib.name, translation_key=translation_key)
1118 )
1119 return items
1120
1121 async def _browse_lib_podcasts(self, library_id: str) -> list[MediaItemType]:
1122 """No sub categories for podcasts."""
1123 if len(self.libraries.podcasts[library_id].item_ids) == 0:
1124 self._log_no_helper_item_ids()
1125 items = []
1126 for podcast_id in self.libraries.podcasts[library_id].item_ids:
1127 mass_item = await self.mass.music.get_library_item_by_prov_id(
1128 media_type=MediaType.PODCAST,
1129 item_id=podcast_id,
1130 provider_instance_id_or_domain=self.instance_id,
1131 )
1132 if mass_item is not None:
1133 items.append(mass_item)
1134 return sorted(items, key=lambda x: x.name)
1135
1136 def _browse_lib_audiobooks(self, current_path: str) -> Sequence[BrowseFolder]:
1137 items = []
1138 for translation_key in AbsBrowseItemsBookTranslationKey:
1139 path = current_path + "/" + ABS_BROWSE_ITEMS_TO_PATH[translation_key]
1140 items.append(
1141 BrowseFolder(
1142 item_id=translation_key.lower(),
1143 name="", # use translation key
1144 translation_key=translation_key,
1145 provider=self.instance_id,
1146 path=path,
1147 )
1148 )
1149 return items
1150
1151 async def _browse_authors(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1152 abs_authors = await self._client.get_library_authors(library_id=library_id)
1153 items = []
1154 for author in abs_authors:
1155 path = f"{current_path}/{author.id_}"
1156 items.append(
1157 BrowseFolder(
1158 item_id=author.id_,
1159 name=author.name,
1160 provider=self.instance_id,
1161 path=path,
1162 )
1163 )
1164
1165 return sorted(items, key=lambda x: x.name)
1166
1167 async def _browse_narrators(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1168 abs_narrators = await self._client.get_library_narrators(library_id=library_id)
1169 items = []
1170 for narrator in abs_narrators:
1171 path = f"{current_path}/{narrator.id_}"
1172 items.append(
1173 BrowseFolder(
1174 item_id=narrator.id_,
1175 name=narrator.name,
1176 provider=self.instance_id,
1177 path=path,
1178 )
1179 )
1180
1181 return sorted(items, key=lambda x: x.name)
1182
1183 async def _browse_series(self, current_path: str, library_id: str) -> Sequence[BrowseFolder]:
1184 items = []
1185 async for response in self._client.get_library_series(library_id=library_id):
1186 if not response.results:
1187 break
1188 for abs_series in response.results:
1189 path = f"{current_path}/{abs_series.id_}"
1190 items.append(
1191 BrowseFolder(
1192 item_id=abs_series.id_,
1193 name=abs_series.name,
1194 provider=self.instance_id,
1195 path=path,
1196 )
1197 )
1198
1199 return sorted(items, key=lambda x: x.name)
1200
1201 async def _browse_collections(
1202 self, current_path: str, library_id: str
1203 ) -> Sequence[BrowseFolder]:
1204 items = []
1205 async for response in self._client.get_library_collections(library_id=library_id):
1206 if not response.results:
1207 break
1208 for abs_collection in response.results:
1209 path = f"{current_path}/{abs_collection.id_}"
1210 items.append(
1211 BrowseFolder(
1212 item_id=abs_collection.id_,
1213 name=abs_collection.name,
1214 provider=self.instance_id,
1215 path=path,
1216 )
1217 )
1218 return sorted(items, key=lambda x: x.name)
1219
1220 async def _browse_books(self, library_id: str) -> Sequence[MediaItemType]:
1221 if len(self.libraries.audiobooks[library_id].item_ids) == 0:
1222 self._log_no_helper_item_ids()
1223 items = []
1224 for book_id in self.libraries.audiobooks[library_id].item_ids:
1225 mass_item = await self.mass.music.get_library_item_by_prov_id(
1226 media_type=MediaType.AUDIOBOOK,
1227 item_id=book_id,
1228 provider_instance_id_or_domain=self.instance_id,
1229 )
1230 if mass_item is not None:
1231 items.append(mass_item)
1232 return sorted(items, key=lambda x: x.name)
1233
1234 async def _browse_author_books(
1235 self, current_path: str, author_id: str
1236 ) -> Sequence[MediaItemType | BrowseFolder]:
1237 items: list[MediaItemType | BrowseFolder] = []
1238
1239 abs_author = await self._client.get_author(
1240 author_id=author_id, include_items=True, include_series=True
1241 )
1242 if not isinstance(abs_author, AbsAuthorWithItemsAndSeries):
1243 raise TypeError("Unexpected type of author.")
1244
1245 book_ids = {x.id_ for x in abs_author.library_items}
1246 series_book_ids = set()
1247
1248 for series in abs_author.series:
1249 series_book_ids.update([x.id_ for x in series.items])
1250 path = f"{current_path}/{series.id_}"
1251 items.append(
1252 BrowseFolder(
1253 item_id=series.id_,
1254 # frontend does <name>: <translation>
1255 name=series.name,
1256 translation_key="series_singular",
1257 provider=self.instance_id,
1258 path=path,
1259 )
1260 )
1261 book_ids = book_ids.difference(series_book_ids)
1262 for book_id in book_ids:
1263 mass_item = await self.mass.music.get_library_item_by_prov_id(
1264 media_type=MediaType.AUDIOBOOK,
1265 item_id=book_id,
1266 provider_instance_id_or_domain=self.instance_id,
1267 )
1268 if mass_item is not None:
1269 items.append(mass_item)
1270
1271 return items
1272
1273 async def _browse_narrator_books(
1274 self, library_id: str, narrator_filter_str: str
1275 ) -> Sequence[MediaItemType]:
1276 items: list[MediaItemType] = []
1277 async for response in self._client.get_library_items(
1278 library_id=library_id, filter_str=f"narrators.{narrator_filter_str}"
1279 ):
1280 if not response.results:
1281 break
1282 for item in response.results:
1283 mass_item = await self.mass.music.get_library_item_by_prov_id(
1284 media_type=MediaType.AUDIOBOOK,
1285 item_id=item.id_,
1286 provider_instance_id_or_domain=self.instance_id,
1287 )
1288 if mass_item is not None:
1289 items.append(mass_item)
1290
1291 return sorted(items, key=lambda x: x.name)
1292
1293 async def _browse_series_books(self, series_id: str) -> Sequence[MediaItemType]:
1294 items = []
1295
1296 abs_series = await self._client.get_series(series_id=series_id, include_progress=True)
1297 if not isinstance(abs_series, AbsSeriesWithProgress):
1298 raise TypeError("Unexpected series type.")
1299
1300 for book_id in abs_series.progress.library_item_ids:
1301 # these are sorted in abs by sequence
1302 mass_item = await self.mass.music.get_library_item_by_prov_id(
1303 media_type=MediaType.AUDIOBOOK,
1304 item_id=book_id,
1305 provider_instance_id_or_domain=self.instance_id,
1306 )
1307 if mass_item is not None:
1308 items.append(mass_item)
1309
1310 return items
1311
1312 async def _browse_collection_books(self, collection_id: str) -> Sequence[MediaItemType]:
1313 items = []
1314 abs_collection = await self._client.get_collection(collection_id=collection_id)
1315 for book in abs_collection.books:
1316 mass_item = await self.mass.music.get_library_item_by_prov_id(
1317 media_type=MediaType.AUDIOBOOK,
1318 item_id=book.id_,
1319 provider_instance_id_or_domain=self.instance_id,
1320 )
1321 if mass_item is not None:
1322 items.append(mass_item)
1323 return items
1324
1325 async def _socket_abs_item_changed(
1326 self, items: LibraryItemExpanded | list[LibraryItemExpanded]
1327 ) -> None:
1328 """For added and updated."""
1329 abs_items = [items] if isinstance(items, LibraryItemExpanded) else items
1330 for abs_item in abs_items:
1331 if isinstance(abs_item, LibraryItemExpandedBook):
1332 # If the book has no audiofiles, we skip -> ebook only.
1333 if len(abs_item.media.tracks) == 0:
1334 continue
1335 self.logger.debug(
1336 'Updated book "%s" via socket.', abs_item.media.metadata.title or ""
1337 )
1338 await self.mass.music.audiobooks.add_item_to_library(
1339 parse_audiobook(
1340 abs_audiobook=abs_item,
1341 instance_id=self.instance_id,
1342 domain=self.domain,
1343 token=self._client.token,
1344 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
1345 ),
1346 overwrite_existing=True,
1347 )
1348 lib = self.libraries.audiobooks.get(abs_item.library_id, None)
1349 if lib is not None:
1350 lib.item_ids.add(abs_item.id_)
1351 elif isinstance(abs_item, LibraryItemExpandedPodcast):
1352 self.logger.debug(
1353 'Updated podcast "%s" via socket.', abs_item.media.metadata.title or ""
1354 )
1355 mass_podcast = parse_podcast(
1356 abs_podcast=abs_item,
1357 instance_id=self.instance_id,
1358 domain=self.domain,
1359 token=self._client.token,
1360 base_url=str(self.config.get_value(CONF_URL)).rstrip("/"),
1361 )
1362 if not (
1363 bool(self.config.get_value(CONF_HIDE_EMPTY_PODCASTS))
1364 and mass_podcast.total_episodes == 0
1365 ):
1366 await self.mass.music.podcasts.add_item_to_library(
1367 mass_podcast,
1368 overwrite_existing=True,
1369 )
1370 lib = self.libraries.podcasts.get(abs_item.library_id, None)
1371 if lib is not None:
1372 lib.item_ids.add(abs_item.id_)
1373 await self._cache_set_helper_libraries()
1374
1375 async def _socket_abs_item_removed(self, item: LibraryItemRemoved) -> None:
1376 """Item removed."""
1377 media_type: MediaType | None = None
1378 for lib in self.libraries.audiobooks.values():
1379 if item.id_ in lib.item_ids:
1380 media_type = MediaType.AUDIOBOOK
1381 lib.item_ids.remove(item.id_)
1382 break
1383 for lib in self.libraries.podcasts.values():
1384 if item.id_ in lib.item_ids:
1385 media_type = MediaType.PODCAST
1386 lib.item_ids.remove(item.id_)
1387 break
1388
1389 if media_type is not None:
1390 mass_item = await self.mass.music.get_library_item_by_prov_id(
1391 media_type=media_type,
1392 item_id=item.id_,
1393 provider_instance_id_or_domain=self.instance_id,
1394 )
1395 if mass_item is not None:
1396 await self.mass.music.remove_item_from_library(
1397 media_type=media_type, library_item_id=mass_item.item_id
1398 )
1399 self.logger.debug('Removed %s "%s" via socket.', media_type.value, mass_item.name)
1400
1401 await self._cache_set_helper_libraries()
1402
1403 async def _socket_abs_user_item_progress_updated(
1404 self, id_: str, progress: MediaProgress
1405 ) -> None:
1406 """To update continue listening.
1407
1408 ABS reports every 15s and immediately on play state change.
1409 This callback is called per item if a progress is changed:
1410 - a change in position
1411 - the item is finished
1412 But it is _not_called, if a progress is reset/ discarded.
1413 """
1414 # guard, see progress guard class docstrings for explanation
1415 if not self.progress_guard.guard_ok_abs(abs_progress=progress):
1416 return
1417
1418 known_ids = self._get_all_known_item_ids()
1419 if progress.library_item_id not in known_ids:
1420 return
1421
1422 self.logger.debug(f"Updated progress of item {progress.library_item_id} via socket.")
1423
1424 if progress.episode_id is None:
1425 await self._update_playlog_book(progress)
1426 return
1427 await self._update_playlog_episode(progress)
1428
1429 async def _socket_abs_refresh_token_expired(self) -> None:
1430 await self.reauthenticate()
1431
1432 async def reauthenticate(self) -> None:
1433 """Reauthorize the abs session config if refresh token expired."""
1434 # some safe guarding should that function be called simultaneously
1435 if self.reauthenticate_lock.locked() or time.time() - self.reauthenticate_last < 5:
1436 while True:
1437 if not self.reauthenticate_lock.locked():
1438 return
1439 await asyncio.sleep(0.5)
1440 async with self.reauthenticate_lock:
1441 await self._client.session_config.authenticate(
1442 username=str(self.config.get_value(CONF_USERNAME)),
1443 password=str(self.config.get_value(CONF_PASSWORD)),
1444 )
1445 self.reauthenticate_last = time.time()
1446
1447 def _get_all_known_item_ids(self) -> set[str]:
1448 known_ids = set()
1449 for lib in self.libraries.podcasts.values():
1450 known_ids.update(lib.item_ids)
1451 for lib in self.libraries.audiobooks.values():
1452 known_ids.update(lib.item_ids)
1453
1454 return known_ids
1455
1456 async def _set_playlog_from_user(self, user: User) -> None:
1457 """Update on user callback.
1458
1459 User holds also all media progresses specific to that user.
1460
1461 The function 'guard_ok_abs' uses the timestamp of the last update in abs, thus after an
1462 initial progress update, an unchanged update will not trigger a (useless) playlog update.
1463
1464 We do not sync removed progresses for the sake of simplicity.
1465 """
1466 await self._set_playlog_from_user_sync(user.media_progress)
1467
1468 async def _set_playlog_from_user_sync(self, progresses: list[MediaProgress]) -> None:
1469 # for debugging
1470 __updated_items = 0
1471
1472 known_ids = self._get_all_known_item_ids()
1473 abs_ids_with_progress = set()
1474
1475 for progress in progresses:
1476 # save progress ids for later
1477 ma_item_id = (
1478 progress.library_item_id
1479 if progress.episode_id is None
1480 else f"{progress.library_item_id} {progress.episode_id}"
1481 )
1482 abs_ids_with_progress.add(ma_item_id)
1483
1484 # Guard. Also makes sure, that we don't write to db again if no state change happened.
1485 # This is achieved by adding a Helper Progress in the update playlog functions, which
1486 # then has the most recent timestamp. If a subsequent progress sent by abs has an older
1487 # timestamp, we do not update again.
1488 if not self.progress_guard.guard_ok_abs(progress):
1489 continue
1490 if progress.current_time is not None:
1491 if int(progress.current_time) != 0 and not progress.current_time >= 30:
1492 # same as mass default, only > 30s
1493 continue
1494 if progress.library_item_id not in known_ids:
1495 continue
1496 __updated_items += 1
1497 if progress.episode_id is None:
1498 await self._update_playlog_book(progress)
1499 else:
1500 await self._update_playlog_episode(progress)
1501 self.logger.debug(f"Updated {__updated_items} from full playlog.")
1502
1503 # Get MA's known progresses of ABS.
1504 # In ABS the user may discard a progress, which removes the progress completely.
1505 # There is no socket notification for this event.
1506 ma_playlog_state = await self.mass.music.get_playlog_provider_item_ids(
1507 provider_instance_id=self.instance_id
1508 )
1509 ma_ids_with_progress = {x for _, x in ma_playlog_state}
1510 discarded_progress_ids = ma_ids_with_progress.difference(abs_ids_with_progress)
1511 for discarded_progress_id in discarded_progress_ids:
1512 if len(discarded_progress_id.split(" ")) == 1:
1513 if discarded_item := await self.mass.music.get_library_item_by_prov_id(
1514 media_type=MediaType.AUDIOBOOK,
1515 item_id=discarded_progress_id,
1516 provider_instance_id_or_domain=self.instance_id,
1517 ):
1518 self.progress_guard.add_progress(discarded_progress_id)
1519 await self.mass.music.mark_item_unplayed(discarded_item)
1520 else:
1521 with suppress(MediaNotFoundError):
1522 discarded_item = await self.get_podcast_episode(
1523 prov_episode_id=discarded_progress_id, add_progress=False
1524 )
1525 self.progress_guard.add_progress(*discarded_progress_id.split(" "))
1526 await self.mass.music.mark_item_unplayed(discarded_item)
1527 self.logger.debug("Discarded item %s ", discarded_progress_id)
1528
1529 async def _update_playlog_book(self, progress: MediaProgress) -> None:
1530 # helper progress also ensures no useless progress updates,
1531 # see comment above
1532 self.progress_guard.add_progress(progress.library_item_id)
1533 if progress.current_time is None:
1534 return
1535 mass_audiobook = await self.mass.music.get_library_item_by_prov_id(
1536 media_type=MediaType.AUDIOBOOK,
1537 item_id=progress.library_item_id,
1538 provider_instance_id_or_domain=self.instance_id,
1539 )
1540 if mass_audiobook is None:
1541 return
1542 if int(progress.current_time) == 0:
1543 await self.mass.music.mark_item_unplayed(mass_audiobook)
1544 else:
1545 await self.mass.music.mark_item_played(
1546 mass_audiobook,
1547 fully_played=progress.is_finished,
1548 seconds_played=int(progress.current_time),
1549 )
1550
1551 async def _update_playlog_episode(self, progress: MediaProgress) -> None:
1552 # helper progress also ensures no useless progress updates,
1553 # see comment above
1554 self.progress_guard.add_progress(progress.library_item_id, progress.episode_id)
1555 if progress.current_time is None:
1556 return
1557 _episode_id = f"{progress.library_item_id} {progress.episode_id}"
1558 try:
1559 # need to obtain full podcast, and then search for episode
1560 mass_episode = await self.get_podcast_episode(_episode_id, add_progress=False)
1561 except MediaNotFoundError:
1562 return
1563 if int(progress.current_time) == 0:
1564 await self.mass.music.mark_item_unplayed(mass_episode)
1565 else:
1566 await self.mass.music.mark_item_played(
1567 mass_episode,
1568 fully_played=progress.is_finished,
1569 seconds_played=int(progress.current_time),
1570 )
1571
1572 async def _cache_set_helper_libraries(self) -> None:
1573 await self.mass.cache.set(
1574 key=CACHE_KEY_LIBRARIES,
1575 provider=self.instance_id,
1576 category=CACHE_CATEGORY_LIBRARIES,
1577 data=self.libraries.to_dict(),
1578 )
1579
1580 def _log_no_libraries(self) -> None:
1581 self.logger.error("There are no libraries visible to the Audiobookshelf provider.")
1582
1583 def _log_no_helper_item_ids(self) -> None:
1584 self.logger.warning(
1585 "Cached item ids are missing. "
1586 "Please trigger a full resync of the Audiobookshelf provider manually."
1587 )
1588