/
/
/
1"""Audible provider for Music Assistant, utilizing the audible library."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import AsyncGenerator, Sequence
7from contextlib import suppress
8from datetime import datetime
9from logging import getLevelName
10from typing import TYPE_CHECKING, cast
11from urllib.parse import quote, unquote
12
13import audible
14from music_assistant_models.enums import MediaType, ProviderFeature
15from music_assistant_models.errors import LoginFailed, MediaNotFoundError
16from music_assistant_models.media_items import BrowseFolder, ItemMapping
17
18from music_assistant.constants import CONF_ENTRY_UNOFFICIAL_PROVIDER
19from music_assistant.models.music_provider import MusicProvider
20from music_assistant.providers.audible.audible_helper import (
21 AudibleHelper,
22 cached_authenticator_from_file,
23 evict_cached_authenticator,
24 refresh_access_token_compat,
25 remove_file,
26)
27
28if TYPE_CHECKING:
29 from music_assistant_models.config_entries import (
30 ConfigEntry,
31 ProviderConfig,
32 )
33 from music_assistant_models.media_items import (
34 Audiobook,
35 MediaItemType,
36 Podcast,
37 PodcastEpisode,
38 )
39 from music_assistant_models.provider import ProviderManifest
40 from music_assistant_models.streamdetails import StreamDetails
41
42 from music_assistant.mass import MusicAssistant
43 from music_assistant.models import ProviderInstanceType
44
45
46# Config keys collected by the setup flow and read back at runtime
47CONF_AUTH_FILE = "auth_file"
48CONF_LOCALE = "locale"
49
50SUPPORTED_FEATURES = {
51 ProviderFeature.BROWSE,
52 ProviderFeature.LIBRARY_AUDIOBOOKS,
53 ProviderFeature.LIBRARY_PODCASTS,
54}
55
56
57async def setup(
58 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
59) -> ProviderInstanceType:
60 """Initialize provider(instance) with given configuration."""
61 return Audibleprovider(mass, manifest, config, SUPPORTED_FEATURES)
62
63
64class Audibleprovider(MusicProvider):
65 """Implementation of a Audible Audiobook Provider."""
66
67 locale: str
68 auth_file: str
69 _client: audible.AsyncClient | None = None
70
71 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
72 """
73 Return the config entries for the Audible provider.
74
75 Authentication (Amazon sign-in on their own page + device registration) runs in the
76 interactive setup flow (see ``setup_flow.py``); this provider has no further options.
77 """
78 return (CONF_ENTRY_UNOFFICIAL_PROVIDER,)
79
80 async def handle_async_init(self) -> None:
81 """Handle asynchronous initialization of the provider."""
82 self.locale = cast("str", self.get_setup_value(CONF_LOCALE) or "us")
83 self.auth_file = cast("str", self.get_setup_value(CONF_AUTH_FILE))
84 self._client: audible.AsyncClient | None = None
85 audible.log_helper.set_level(getLevelName(self.logger.level))
86 await self._login()
87
88 async def _login(self) -> None:
89 """Authenticate with Audible using the saved authentication file."""
90 try:
91 # the cache is keyed on the auth file path, so a reconfigure (which writes
92 # a new auth file) never reuses the previous registration's authenticator
93 auth = await cached_authenticator_from_file(self.auth_file, self.locale)
94
95 # Check if we have signing auth (preferred, stable - not affected by API changes)
96 has_signing_auth = auth.adp_token and auth.device_private_key
97 if has_signing_auth:
98 self.logger.debug("Using signing auth (stable RSA-signed requests)")
99 else:
100 self.logger.debug("Signing auth not available, using bearer auth")
101
102 # Handle token refresh if needed
103 if auth.access_token_expired:
104 self.logger.debug("Access token expired, refreshing")
105 try:
106 # Use compatible refresh that handles new API token format
107 if auth.refresh_token and auth.locale:
108 refresh_data = await refresh_access_token_compat(
109 refresh_token=auth.refresh_token,
110 domain=auth.locale.domain,
111 http_session=self.mass.http_session,
112 with_username=auth.with_username or False,
113 )
114 auth._update_attrs(**refresh_data)
115 await asyncio.to_thread(auth.to_file, self.auth_file)
116 self.logger.debug("Token refreshed successfully")
117 else:
118 self.logger.warning("Cannot refresh: missing refresh_token or locale")
119 except Exception as refresh_error:
120 self.logger.warning(f"Token refresh failed: {refresh_error}")
121 if not has_signing_auth:
122 # Only fail if we don't have signing auth as fallback
123 raise LoginFailed(
124 "Token refresh failed and signing auth not available. "
125 "Please re-authenticate with Audible."
126 ) from refresh_error
127 # Continue with signing auth
128
129 self._client = audible.AsyncClient(auth)
130
131 self.helper = AudibleHelper(
132 mass=self.mass,
133 client=self._client,
134 provider_instance=self.instance_id,
135 provider_domain=self.domain,
136 provider=self,
137 logger=self.logger,
138 )
139
140 self.logger.info("Successfully authenticated with Audible.")
141
142 except LoginFailed:
143 raise
144 except Exception as e:
145 self.logger.error(f"Failed to authenticate with Audible: {e}")
146 raise LoginFailed(f"Failed to authenticate with Audible: {e}") from e
147
148 @property
149 def is_streaming_provider(self) -> bool:
150 """Return True if the provider is a streaming provider."""
151 return True
152
153 async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook]:
154 """Get all audiobooks from the library."""
155 async for audiobook in self.helper.get_library():
156 yield audiobook
157
158 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
159 """Get full audiobook details by id."""
160 return await self.helper.get_audiobook(asin=prov_audiobook_id, use_cache=False)
161
162 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
163 """
164 Browse this provider's items.
165
166 :param path: The path to browse, (e.g. provider_id://authors).
167 """
168 item_path = path.split("://", 1)[1] if "://" in path else ""
169 parts = item_path.split("/") if item_path else []
170
171 # Root - return main folders
172 if not item_path:
173 return self._browse_root(path)
174
175 # Authors listing
176 if parts[0] == "authors":
177 if len(parts) == 1:
178 return await self._browse_authors(path)
179 # Specific author's books
180 return await self._browse_author_books(unquote(parts[1]))
181
182 # Series listing
183 if parts[0] == "series":
184 if len(parts) == 1:
185 return await self._browse_series(path)
186 # Specific series' books
187 return await self._browse_series_books(unquote(parts[1]))
188
189 # Narrators listing
190 if parts[0] == "narrators":
191 if len(parts) == 1:
192 return await self._browse_narrators(path)
193 return await self._browse_narrator_books(unquote(parts[1]))
194
195 # Genres listing
196 if parts[0] == "genres":
197 if len(parts) == 1:
198 return await self._browse_genres(path)
199 return await self._browse_genre_books(unquote(parts[1]))
200
201 # Publishers listing
202 if parts[0] == "publishers":
203 if len(parts) == 1:
204 return await self._browse_publishers(path)
205 return await self._browse_publisher_books(unquote(parts[1]))
206
207 # Fall back to base implementation for audiobooks/podcasts
208 return await super().browse(path)
209
210 def _browse_root(self, base_path: str) -> list[BrowseFolder]:
211 """Return root browse folders."""
212 return [
213 BrowseFolder(
214 item_id="audiobooks",
215 provider=self.instance_id,
216 path=f"{base_path}audiobooks",
217 name="Audiobooks",
218 translation_key="audiobooks",
219 ),
220 BrowseFolder(
221 item_id="podcasts",
222 provider=self.instance_id,
223 path=f"{base_path}podcasts",
224 name="Podcasts",
225 translation_key="podcasts",
226 ),
227 BrowseFolder(
228 item_id="authors",
229 provider=self.instance_id,
230 path=f"{base_path}authors",
231 name="Authors",
232 translation_key="authors",
233 ),
234 BrowseFolder(
235 item_id="series",
236 provider=self.instance_id,
237 path=f"{base_path}series",
238 name="Series",
239 translation_key="series",
240 ),
241 BrowseFolder(
242 item_id="narrators",
243 provider=self.instance_id,
244 path=f"{base_path}narrators",
245 name="Narrators",
246 translation_key="narrators",
247 ),
248 BrowseFolder(
249 item_id="genres",
250 provider=self.instance_id,
251 path=f"{base_path}genres",
252 name="Genres",
253 translation_key="genres",
254 ),
255 BrowseFolder(
256 item_id="publishers",
257 provider=self.instance_id,
258 path=f"{base_path}publishers",
259 name="Publishers",
260 translation_key="publishers",
261 ),
262 ]
263
264 async def _browse_authors(self, base_path: str) -> list[BrowseFolder]:
265 """Return list of all authors."""
266 authors = await self.helper.get_authors()
267 return [
268 BrowseFolder(
269 item_id=asin,
270 provider=self.instance_id,
271 path=f"{base_path}/{quote(asin)}",
272 name=name,
273 )
274 for asin, name in sorted(authors.items(), key=lambda x: x[1])
275 ]
276
277 async def _browse_author_books(self, author_asin: str) -> list[Audiobook]:
278 """Return audiobooks by a specific author."""
279 return await self.helper.get_audiobooks_by_author(author_asin)
280
281 async def _browse_series(self, base_path: str) -> list[BrowseFolder]:
282 """Return list of all series."""
283 series = await self.helper.get_series()
284 return [
285 BrowseFolder(
286 item_id=asin,
287 provider=self.instance_id,
288 path=f"{base_path}/{quote(asin)}",
289 name=title,
290 )
291 for asin, title in sorted(series.items(), key=lambda x: x[1])
292 ]
293
294 async def _browse_series_books(self, series_asin: str) -> list[Audiobook]:
295 """Return audiobooks in a specific series."""
296 return await self.helper.get_audiobooks_by_series(series_asin)
297
298 async def _browse_narrators(self, base_path: str) -> list[BrowseFolder]:
299 """Return list of all narrators."""
300 narrators = await self.helper.get_narrators()
301 return [
302 BrowseFolder(
303 item_id=asin,
304 provider=self.instance_id,
305 path=f"{base_path}/{quote(asin)}",
306 name=name,
307 )
308 for asin, name in sorted(narrators.items(), key=lambda x: x[1])
309 ]
310
311 async def _browse_narrator_books(self, narrator_asin: str) -> list[Audiobook]:
312 """Return audiobooks by a specific narrator."""
313 return await self.helper.get_audiobooks_by_narrator(narrator_asin)
314
315 async def _browse_genres(self, base_path: str) -> list[BrowseFolder]:
316 """Return list of all genres."""
317 genres = await self.helper.get_genres()
318 return [
319 BrowseFolder(
320 item_id=genre,
321 provider=self.instance_id,
322 path=f"{base_path}/{quote(genre)}",
323 name=genre,
324 )
325 for genre in sorted(genres)
326 ]
327
328 async def _browse_genre_books(self, genre: str) -> list[Audiobook]:
329 """Return audiobooks matching a genre."""
330 return await self.helper.get_audiobooks_by_genre(genre)
331
332 async def _browse_publishers(self, base_path: str) -> list[BrowseFolder]:
333 """Return list of all publishers."""
334 publishers = await self.helper.get_publishers()
335 return [
336 BrowseFolder(
337 item_id=publisher,
338 provider=self.instance_id,
339 path=f"{base_path}/{quote(publisher)}",
340 name=publisher,
341 )
342 for publisher in sorted(publishers)
343 ]
344
345 async def _browse_publisher_books(self, publisher: str) -> list[Audiobook]:
346 """Return audiobooks from a specific publisher."""
347 return await self.helper.get_audiobooks_by_publisher(publisher)
348
349 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
350 """Get all podcasts from the library."""
351 async for podcast in self.helper.get_library_podcasts():
352 yield podcast
353
354 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
355 """Get full podcast details by id."""
356 return await self.helper.get_podcast(asin=prov_podcast_id)
357
358 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
359 """Get all episodes for a podcast."""
360 async for episode in self.helper.get_podcast_episodes(prov_podcast_id):
361 yield episode
362
363 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
364 """Get full podcast episode details by id."""
365 return await self.helper.get_podcast_episode(prov_episode_id)
366
367 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
368 """
369 Get stream details for an audiobook or podcast episode.
370
371 :param item_id: The ASIN of the audiobook or podcast episode.
372 :param media_type: The type of media (audiobook or podcast episode).
373 """
374 try:
375 return await self.helper.get_stream(asin=item_id, media_type=media_type)
376 except ValueError as exc:
377 raise MediaNotFoundError(f"Failed to get stream details for {item_id}") from exc
378
379 async def on_played(
380 self,
381 media_type: MediaType,
382 prov_item_id: str,
383 fully_played: bool,
384 position: int,
385 media_item: MediaItemType,
386 is_playing: bool = False,
387 ) -> None:
388 """
389 Handle callback when a (playable) media item has been played.
390
391 This is called by the Queue controller when;
392 - a track has been fully played
393 - a track has been stopped (or skipped) after being played
394 - every 30s when a track is playing
395
396 Fully played is True when the track has been played to the end.
397
398 Position is the last known position of the track in seconds, to sync resume state.
399 When fully_played is set to false and position is 0,
400 the user marked the item as unplayed in the UI.
401
402 is_playing is True when the track is currently playing.
403
404 media_item is the full media item details of the played/playing track.
405 """
406 await self.helper.set_last_position(prov_item_id, position, media_type)
407
408 async def get_resume_position(
409 self, item_id: str, media_type: MediaType
410 ) -> tuple[bool, int, datetime | None]:
411 """
412 Return the resume position from Audible for the given item.
413
414 :param item_id: The provider item ID (ASIN) of the audiobook.
415 :param media_type: The media type of the item.
416 """
417 if media_type != MediaType.AUDIOBOOK:
418 raise NotImplementedError
419 return await self.helper.get_audible_resume_position(item_id)
420
421 async def unload(self, is_removed: bool = False) -> None:
422 """
423 Handle unload/close of the provider.
424
425 Called when provider is deregistered (e.g. MA exiting or config reloading).
426 is_removed will be set to True when the provider is removed from the configuration.
427 """
428 if is_removed:
429 try:
430 await self.helper.deregister()
431 finally:
432 evict_cached_authenticator(self.auth_file)
433 with suppress(OSError):
434 await remove_file(self.auth_file)
435