/
/
/
1"""Internet Archive music provider implementation."""
2
3from __future__ import annotations
4
5import contextlib
6import re
7from collections.abc import AsyncGenerator
8from typing import TYPE_CHECKING, Any
9
10import aiohttp
11from music_assistant_models.enums import MediaType, ProviderFeature
12from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
13from music_assistant_models.media_items import (
14 Album,
15 Artist,
16 Audiobook,
17 MediaItemChapter,
18 Podcast,
19 PodcastEpisode,
20 ProviderMapping,
21 SearchResults,
22 Track,
23)
24from music_assistant_models.unique_list import UniqueList
25
26from music_assistant.constants import UNKNOWN_ARTIST
27from music_assistant.controllers.cache import use_cache
28from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
29from music_assistant.models.music_provider import MusicProvider
30
31from .helpers import InternetArchiveClient, clean_text, extract_year, parse_duration
32from .parsers import (
33 add_item_image,
34 artist_exists,
35 create_artist,
36 create_provider_mapping,
37 create_title_from_identifier,
38 doc_to_album,
39 doc_to_audiobook,
40 doc_to_podcast,
41 doc_to_track,
42 is_audiobook_content,
43 is_likely_album,
44 is_podcast_content,
45)
46from .streaming import InternetArchiveStreaming
47
48if TYPE_CHECKING:
49 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
50 from music_assistant_models.provider import ProviderManifest
51 from music_assistant_models.streamdetails import StreamDetails
52
53 from music_assistant import MusicAssistant
54
55
56class InternetArchiveProvider(MusicProvider):
57 """Implementation of Internet Archive music provider."""
58
59 def __init__(
60 self,
61 mass: MusicAssistant,
62 manifest: ProviderManifest,
63 config: ProviderConfig,
64 supported_features: set[ProviderFeature],
65 ) -> None:
66 """Initialize the provider."""
67 super().__init__(mass, manifest, config, supported_features)
68 self.throttler = ThrottlerManager(
69 rate_limit=10, period=60, retry_attempts=5, initial_backoff=5
70 )
71 self.client = InternetArchiveClient(mass)
72 self.streaming = InternetArchiveStreaming(self)
73
74 @property
75 def max_concurrent_streams(self) -> None:
76 """Allow unlimited concurrent upstream source streams."""
77 return None
78
79 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
80 """Return Config entries to configure this provider."""
81 return ()
82
83 @property
84 def is_streaming_provider(self) -> bool:
85 """Return True if provider is a streaming provider."""
86 return True
87
88 @property
89 def supported_media_types(self) -> set[MediaType]:
90 """Return the media types this provider can serve."""
91 # catalogue access is search/browse only, there are no library items at all
92 return {
93 MediaType.ARTIST,
94 MediaType.ALBUM,
95 MediaType.TRACK,
96 MediaType.AUDIOBOOK,
97 MediaType.PODCAST,
98 }
99
100 @throttle_with_retries
101 async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
102 """Make a GET request and return JSON response with throttling."""
103 return await self.client._get_json(url, params)
104
105 @throttle_with_retries
106 async def _search(self, **kwargs: Any) -> dict[str, Any]:
107 """Throttled search wrapper."""
108 return await self.client.search(**kwargs)
109
110 @throttle_with_retries
111 async def _get_metadata(self, identifier: str) -> dict[str, Any]:
112 """Throttled metadata wrapper."""
113 return await self.client.get_metadata(identifier)
114
115 @use_cache(expiration=86400 * 30) # 30 days - file listings are static
116 @throttle_with_retries
117 async def _get_audio_files(self, identifier: str) -> list[dict[str, Any]]:
118 """Throttled audio files wrapper."""
119 return await self.client.get_audio_files(identifier)
120
121 @use_cache(86400 * 7) # 7 days
122 async def search(
123 self,
124 search_query: str,
125 media_types: list[MediaType],
126 limit: int = 5,
127 ) -> SearchResults:
128 """
129 Perform search on Internet Archive.
130
131 Uses multiple search strategies to maximize result coverage with
132 proper result accumulation and broader search patterns.
133
134 Args:
135 search_query: The search term to look for
136 media_types: List of media types to search for
137 limit: Maximum number of results to return per media type
138
139 Returns:
140 SearchResults object containing found items
141 """
142 if not search_query.strip():
143 return SearchResults()
144
145 # Adjust search intensity based on what's being requested
146 rows_per_strategy = min(limit * 2, 16) if len(media_types) > 1 else min(limit * 2, 100)
147
148 # Collect results in separate lists
149 tracks: list[Track] = []
150 albums: list[Album] = []
151 artists: list[Artist] = []
152 audiobooks: list[Audiobook] = []
153 podcasts: list[Podcast] = []
154
155 # Track processed identifiers to avoid duplicates across strategies
156 processed_ids: set[str] = set()
157
158 # Build search strategies based on requested media types
159 search_strategies = []
160
161 # For music searches: focus on title and creator
162 # Include both mediatype:audio and mediatype:etree (Live Music Archive)
163 if any(mt in media_types for mt in [MediaType.TRACK, MediaType.ALBUM, MediaType.ARTIST]):
164 search_strategies.extend(
165 [
166 (
167 f"creator:({search_query}) AND (mediatype:audio OR mediatype:etree)",
168 "downloads desc",
169 ),
170 (
171 f"title:({search_query}) AND (mediatype:audio OR mediatype:etree)",
172 "downloads desc",
173 ),
174 (
175 f"subject:({search_query}) AND (mediatype:audio OR mediatype:etree)",
176 "downloads desc",
177 ),
178 ]
179 )
180
181 # For audiobooks: search within audiobook collections, still limit to audio
182 if MediaType.AUDIOBOOK in media_types:
183 audiobook_query = f"{search_query} AND collection:(librivoxaudio OR audio_bookspoetry) AND mediatype:audio"
184 search_strategies.append((audiobook_query, "downloads desc"))
185
186 # For podcasts: search within podcast collections
187 if MediaType.PODCAST in media_types:
188 podcast_query = f"{search_query} AND collection:podcasts AND mediatype:audio"
189 search_strategies.append((podcast_query, "downloads desc"))
190
191 for strategy_idx, (strategy_query, sort_order) in enumerate(search_strategies):
192 self.logger.debug("Trying search strategy %d: %s", strategy_idx + 1, strategy_query)
193
194 try:
195 search_response = await self._search(
196 query=strategy_query,
197 rows=rows_per_strategy,
198 sort=sort_order,
199 )
200
201 response_data = search_response.get("response", {})
202 docs = response_data.get("docs", [])
203 self.logger.debug(
204 "Strategy %d '%s' found %d raw results",
205 strategy_idx + 1,
206 strategy_query,
207 len(docs),
208 )
209
210 # Process results and extract different media types
211 strategy_processed = 0
212 strategy_skipped = 0
213
214 for doc in docs:
215 try:
216 identifier = doc.get("identifier")
217 if not identifier or identifier in processed_ids:
218 strategy_skipped += 1
219 continue
220
221 # Track this identifier to avoid duplicates
222 processed_ids.add(identifier)
223
224 await self._process_search_result(
225 doc, tracks, albums, artists, audiobooks, podcasts, media_types
226 )
227 strategy_processed += 1
228
229 # Check if we have enough results across all types
230 if self._has_sufficient_results(
231 tracks, albums, artists, audiobooks, podcasts, media_types, limit
232 ):
233 self.logger.debug(
234 "Sufficient results found after strategy %d, stopping search",
235 strategy_idx + 1,
236 )
237 break
238
239 except (InvalidDataError, KeyError) as err:
240 self.logger.debug("Skipping invalid search result: %s", err)
241 strategy_skipped += 1
242 continue
243
244 self.logger.debug(
245 "Strategy %d '%s': processed %d new items, skipped %d items. "
246 "Running totals - tracks: %d, albums: %d, artists: %d, "
247 "audiobooks: %d, podcasts: %d",
248 strategy_idx + 1,
249 strategy_query,
250 strategy_processed,
251 strategy_skipped,
252 len(tracks),
253 len(albums),
254 len(artists),
255 len(audiobooks),
256 len(podcasts),
257 )
258
259 # If we have sufficient results, stop trying more strategies
260 if self._has_sufficient_results(
261 tracks, albums, artists, audiobooks, podcasts, media_types, limit
262 ):
263 break
264
265 except Exception as err:
266 self.logger.warning("Search strategy %d failed: %s", strategy_idx + 1, err)
267 continue
268
269 # Log final results for debugging
270 self.logger.debug(
271 "Search for '%s' completed. Final results - tracks: %d, albums: %d, "
272 "artists: %d, audiobooks: %d, podcasts: %d (processed %d unique items)",
273 search_query,
274 len(tracks),
275 len(albums),
276 len(artists),
277 len(audiobooks),
278 len(podcasts),
279 len(processed_ids),
280 )
281
282 return SearchResults(
283 tracks=tracks[:limit] if MediaType.TRACK in media_types else [],
284 albums=albums[:limit] if MediaType.ALBUM in media_types else [],
285 artists=artists[:limit] if MediaType.ARTIST in media_types else [],
286 audiobooks=audiobooks[:limit] if MediaType.AUDIOBOOK in media_types else [],
287 podcasts=podcasts[:limit] if MediaType.PODCAST in media_types else [],
288 )
289
290 def _has_sufficient_results(
291 self,
292 tracks: list[Track],
293 albums: list[Album],
294 artists: list[Artist],
295 audiobooks: list[Audiobook],
296 podcasts: list[Podcast],
297 media_types: list[MediaType],
298 limit: int,
299 ) -> bool:
300 """Check if we have sufficient results for all requested media types."""
301 return (
302 (MediaType.TRACK not in media_types or len(tracks) >= limit)
303 and (MediaType.ALBUM not in media_types or len(albums) >= limit)
304 and (MediaType.ARTIST not in media_types or len(artists) >= limit)
305 and (MediaType.AUDIOBOOK not in media_types or len(audiobooks) >= limit)
306 and (MediaType.PODCAST not in media_types or len(podcasts) >= limit)
307 )
308
309 async def _process_search_result(
310 self,
311 doc: dict[str, Any],
312 tracks: list[Track],
313 albums: list[Album],
314 artists: list[Artist],
315 audiobooks: list[Audiobook],
316 podcasts: list[Podcast],
317 media_types: list[MediaType],
318 ) -> None:
319 """
320 Process a single search result document from Internet Archive.
321
322 Determines the appropriate media type and creates corresponding objects.
323 Uses improved heuristics to classify items as tracks, albums, or audiobooks.
324 """
325 identifier = doc.get("identifier")
326 if not identifier:
327 raise InvalidDataError("Missing identifier in search result")
328
329 title = clean_text(doc.get("title"))
330 creator = clean_text(doc.get("creator"))
331
332 # Be lenient - allow items without title if they have identifier
333 if not title and not identifier:
334 raise InvalidDataError("Missing both title and identifier in search result")
335
336 # Use identifier as fallback title if needed
337 if not title:
338 title = create_title_from_identifier(identifier)
339
340 # Determine what type of item this is
341 mediatype = doc.get("mediatype", "")
342 collection = doc.get("collection", [])
343 if isinstance(collection, str):
344 collection = [collection]
345
346 # Check if this is audiobook content using improved detection
347 if is_audiobook_content(doc) and MediaType.AUDIOBOOK in media_types:
348 audiobook = doc_to_audiobook(
349 doc, self.domain, self.instance_id, self.client.get_item_url
350 )
351 if audiobook:
352 audiobooks.append(audiobook)
353 return # Don't process as other media types
354
355 # Check if this is podcast content
356 if is_podcast_content(doc) and MediaType.PODCAST in media_types:
357 podcast = doc_to_podcast(doc, self.domain, self.instance_id, self.client.get_item_url)
358 if podcast:
359 podcasts.append(podcast)
360 return # Don't process as other media types
361
362 # For etree items, usually each item is an album (concert)
363 if mediatype == "etree" or "etree" in collection:
364 if MediaType.ALBUM in media_types:
365 album = doc_to_album(doc, self.domain, self.instance_id, self.client.get_item_url)
366 if album:
367 albums.append(album)
368
369 if MediaType.ARTIST in media_types and creator:
370 artist = create_artist(creator, self.domain, self.instance_id)
371 if artist and not artist_exists(artist, artists):
372 artists.append(artist)
373
374 elif mediatype == "audio":
375 # Use heuristics to determine album vs track without expensive API calls
376 if is_likely_album(doc):
377 if MediaType.ALBUM in media_types:
378 album = doc_to_album(
379 doc, self.domain, self.instance_id, self.client.get_item_url
380 )
381 if album:
382 albums.append(album)
383 elif MediaType.TRACK in media_types:
384 track = doc_to_track(doc, self.domain, self.instance_id, self.client.get_item_url)
385 if track:
386 tracks.append(track)
387
388 if MediaType.ARTIST in media_types and creator:
389 artist = create_artist(creator, self.domain, self.instance_id)
390 if artist and not artist_exists(artist, artists):
391 artists.append(artist)
392
393 @use_cache(expiration=86400 * 60) # Cache for 60 days - artist "tracks" change infrequently
394 async def get_track(self, prov_track_id: str) -> Track:
395 """Get full track details by id."""
396 metadata = await self._get_metadata(prov_track_id)
397 item_metadata = metadata.get("metadata", {})
398
399 title = clean_text(item_metadata.get("title"))
400 creator = clean_text(item_metadata.get("creator"))
401
402 if not title:
403 raise MediaNotFoundError(f"Track {prov_track_id} not found or invalid")
404
405 track = Track(
406 item_id=prov_track_id,
407 provider=self.instance_id,
408 name=title,
409 provider_mappings={
410 create_provider_mapping(
411 prov_track_id, self.domain, self.instance_id, self.client.get_item_url
412 )
413 },
414 )
415
416 # Add artist
417 if creator:
418 track.artists = UniqueList([create_artist(creator, self.domain, self.instance_id)])
419 else:
420 track.artists = UniqueList(
421 [create_artist(UNKNOWN_ARTIST, self.domain, self.instance_id)]
422 )
423
424 # Add duration from first audio file
425 try:
426 audio_files = await self._get_audio_files(prov_track_id)
427 if audio_files and audio_files[0].get("length"):
428 duration = parse_duration(audio_files[0]["length"])
429 if duration:
430 track.duration = duration
431 except (TimeoutError, aiohttp.ClientError) as err:
432 self.logger.debug("Network error getting duration for track %s: %s", prov_track_id, err)
433 except (KeyError, ValueError, TypeError) as err:
434 self.logger.debug("Could not parse duration for track %s: %s", prov_track_id, err)
435
436 # Add metadata
437 if description := clean_text(item_metadata.get("description")):
438 track.metadata.description = description
439
440 # Add thumbnail
441 add_item_image(track, prov_track_id, self.instance_id)
442
443 return track
444
445 @use_cache(expiration=86400 * 60) # Cache for 60 days - album catalogs change infrequently
446 async def get_album(self, prov_album_id: str) -> Album:
447 """Get full album details by id."""
448 metadata = await self._get_metadata(prov_album_id)
449 item_metadata = metadata.get("metadata", {})
450
451 title = clean_text(item_metadata.get("title"))
452 creator = clean_text(item_metadata.get("creator"))
453
454 if not title:
455 raise MediaNotFoundError(f"Album {prov_album_id} not found or invalid")
456
457 album = Album(
458 item_id=prov_album_id,
459 provider=self.instance_id,
460 name=title,
461 provider_mappings={
462 create_provider_mapping(
463 prov_album_id, self.domain, self.instance_id, self.client.get_item_url
464 )
465 },
466 )
467
468 # Add artist
469 if creator:
470 album.artists = UniqueList([create_artist(creator, self.domain, self.instance_id)])
471 else:
472 album.artists = UniqueList(
473 [create_artist(UNKNOWN_ARTIST, self.domain, self.instance_id)]
474 )
475
476 # Add metadata
477 if date := extract_year(item_metadata.get("date")):
478 album.year = date
479
480 if description := clean_text(item_metadata.get("description")):
481 album.metadata.description = description
482
483 # Add thumbnail
484 add_item_image(album, prov_album_id, self.instance_id)
485
486 return album
487
488 @use_cache(expiration=86400 * 60) # Cache for 60 days - artist catalogs change infrequently
489 async def get_artist(self, prov_artist_id: str) -> Artist:
490 """
491 Get full artist details by id.
492
493 Args:
494 prov_artist_id: Provider-specific artist identifier (artist name)
495
496 Returns:
497 Artist object
498 """
499 # Artist IDs are just the creator names
500 return Artist(
501 item_id=prov_artist_id,
502 provider=self.instance_id,
503 name=prov_artist_id,
504 provider_mappings={
505 ProviderMapping(
506 item_id=prov_artist_id,
507 provider_domain=self.domain,
508 provider_instance=self.instance_id,
509 )
510 },
511 )
512
513 @use_cache(expiration=86400 * 30) # Cache for 30 days - audiobook catalogs change infrequently
514 async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook:
515 """Get full audiobook details by id."""
516 metadata = await self._get_metadata(prov_audiobook_id)
517 item_metadata = metadata.get("metadata", {})
518
519 title = clean_text(item_metadata.get("title"))
520 creator = clean_text(item_metadata.get("creator"))
521
522 if not title:
523 raise MediaNotFoundError(f"Audiobook {prov_audiobook_id} not found or invalid")
524
525 audiobook = Audiobook(
526 item_id=prov_audiobook_id,
527 provider=self.instance_id,
528 name=title,
529 provider_mappings={
530 create_provider_mapping(
531 prov_audiobook_id, self.domain, self.instance_id, self.client.get_item_url
532 )
533 },
534 )
535
536 # Add author/narrator
537 if creator:
538 author_list = [creator]
539 audiobook.authors = UniqueList(author_list)
540
541 # Add metadata
542 if description := clean_text(item_metadata.get("description")):
543 audiobook.metadata.description = description
544
545 # Add thumbnail
546 add_item_image(audiobook, prov_audiobook_id, self.instance_id)
547
548 # Calculate duration and chapters
549 try:
550 total_duration, chapters = await self._calculate_audiobook_duration_and_chapters(
551 prov_audiobook_id
552 )
553 audiobook.duration = total_duration
554 if len(chapters) > 1:
555 audiobook.metadata.chapters = chapters
556
557 except Exception as err:
558 self.logger.warning(
559 f"Could not process audio files for audiobook {prov_audiobook_id}: {err}"
560 )
561 audiobook.duration = 0
562 audiobook.metadata.chapters = []
563
564 return audiobook
565
566 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
567 """Get album tracks for given album id."""
568 metadata = await self._get_metadata(prov_album_id)
569 item_metadata = metadata.get("metadata", {})
570 audio_files = await self._get_audio_files(prov_album_id)
571 tracks = []
572
573 # Pre-create album artist to avoid duplicates
574 album_artist = clean_text(item_metadata.get("creator"))
575 album_artist_normalized = album_artist.lower() if album_artist else ""
576 album_artist_obj = None
577 if album_artist:
578 album_artist_obj = create_artist(album_artist, self.domain, self.instance_id)
579 else:
580 album_artist_obj = create_artist(UNKNOWN_ARTIST, self.domain, self.instance_id)
581
582 for i, file_info in enumerate(audio_files, 1):
583 filename = file_info.get("name", "")
584
585 # Use file's title if available, otherwise clean up filename
586 track_name = file_info.get("title", filename)
587 if not track_name or track_name == filename:
588 track_name = filename.rsplit(".", 1)[0] if "." in filename else filename
589
590 # Try to extract track number from file metadata first, then filename
591 track_number = self._extract_track_number(file_info, track_name, i)
592
593 track = Track(
594 item_id=f"{prov_album_id}#{filename}",
595 provider=self.instance_id,
596 name=track_name,
597 track_number=track_number,
598 provider_mappings={
599 ProviderMapping(
600 item_id=f"{prov_album_id}#{filename}",
601 provider_domain=self.domain,
602 provider_instance=self.instance_id,
603 url=self.client.get_download_url(prov_album_id, filename),
604 available=True,
605 )
606 },
607 )
608
609 # Add file-specific artist if available, otherwise use album artist
610 file_artist = file_info.get("artist") or file_info.get("creator")
611 if file_artist:
612 file_artist_cleaned = clean_text(file_artist)
613 file_artist_normalized = file_artist_cleaned.lower()
614 # Check if this is the same as album artist to avoid duplicates (case-insensitive)
615 if album_artist_normalized and file_artist_normalized == album_artist_normalized:
616 track.artists = UniqueList([album_artist_obj])
617 else:
618 track.artists = UniqueList(
619 [create_artist(file_artist_cleaned, self.domain, self.instance_id)]
620 )
621 else:
622 # Use pre-created album artist object
623 track.artists = UniqueList([album_artist_obj])
624
625 # Add duration if available
626 if duration_str := file_info.get("length"):
627 if duration := parse_duration(duration_str):
628 track.duration = duration
629
630 # Add genre if available
631 if genre := file_info.get("genre"):
632 track.metadata.genres = {clean_text(genre)}
633
634 tracks.append(track)
635
636 return tracks
637
638 def _extract_track_number(
639 self, file_info: dict[str, Any], track_name: str, fallback: int
640 ) -> int:
641 """Extract track number from file metadata or filename."""
642 track_number = None
643
644 if "track" in file_info:
645 with contextlib.suppress(ValueError, AttributeError):
646 track_number = int(str(file_info["track"]).split("/")[0])
647
648 if track_number is None:
649 # Fallback to filename parsing
650 track_num_match = re.search(r"^(\d+)[\s\-_.]*(.+)", track_name)
651 track_number = int(track_num_match.group(1)) if track_num_match else fallback
652
653 return track_number
654
655 @use_cache(
656 expiration=86400 * 30, allow_expired_cache=True
657 ) # Cache for 30 days - artist catalogs change infrequently
658 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
659 """
660 Get albums for a specific artist.
661
662 Uses metadata heuristics to determine likely albums without expensive
663 API calls for better performance.
664
665 Args:
666 prov_artist_id: Provider-specific artist identifier (artist name)
667
668 Returns:
669 List of Album objects by the artist
670 """
671 albums: list[Album] = []
672 page = 0
673 page_size = 200 # IA's maximum
674
675 while len(albums) < 1000: # Reasonable upper limit
676 search_response = await self._search(
677 query=f'creator:"{prov_artist_id}" AND (format:"VBR MP3" OR format:"FLAC" \
678 OR format:"Ogg Vorbis")',
679 sort="downloads desc",
680 rows=page_size,
681 page=page,
682 )
683
684 docs = search_response.get("response", {}).get("docs", [])
685 if not docs:
686 break
687
688 for doc in docs:
689 try:
690 # Use metadata heuristics instead of expensive API calls
691 # to determine if item is an album
692 if is_likely_album(doc):
693 album = doc_to_album(
694 doc, self.domain, self.instance_id, self.client.get_item_url
695 )
696 if album:
697 albums.append(album)
698 except (KeyError, ValueError, TypeError) as err:
699 self.logger.debug(
700 "Skipping invalid album for artist %s: %s", prov_artist_id, err
701 )
702 continue
703 except (TimeoutError, aiohttp.ClientError) as err:
704 self.logger.debug(
705 "Network error processing album for artist %s: %s", prov_artist_id, err
706 )
707 continue
708 except Exception:
709 self.logger.exception(
710 "Unexpected error processing album for artist %s", prov_artist_id
711 )
712 continue
713 page += 1
714 return albums
715
716 @use_cache(expiration=86400 * 7, allow_expired_cache=True) # Cache for 1 week
717 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
718 """
719 Get top tracks for a specific artist.
720
721 Uses the same search as get_artist_albums but filters for single tracks.
722
723 Args:
724 prov_artist_id: Provider-specific artist identifier (artist name)
725
726 Returns:
727 List of Track objects representing the artist's top tracks
728 """
729 tracks = []
730 search_response = await self._search(
731 query=(
732 f'creator:"{prov_artist_id}" AND '
733 f'(format:"VBR MP3" OR format:"FLAC" OR format:"Ogg Vorbis")'
734 ),
735 rows=25, # Limit for "top" tracks
736 sort="downloads desc",
737 )
738
739 response_data = search_response.get("response", {})
740 docs = response_data.get("docs", [])
741
742 for doc in docs:
743 try:
744 # Only include items that are NOT classified as albums
745 if not is_likely_album(doc):
746 track = doc_to_track(
747 doc, self.domain, self.instance_id, self.client.get_item_url
748 )
749 if track:
750 tracks.append(track)
751 except (KeyError, ValueError, TypeError) as err:
752 self.logger.debug("Skipping invalid track for artist %s: %s", prov_artist_id, err)
753 continue
754 except (TimeoutError, aiohttp.ClientError) as err:
755 self.logger.debug(
756 "Network error processing track for artist %s: %s", prov_artist_id, err
757 )
758 continue
759 except Exception:
760 self.logger.exception(
761 "Unexpected error processing track for artist %s", prov_artist_id
762 )
763 continue
764
765 if len(tracks) >= 25:
766 break
767
768 return tracks
769
770 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
771 """
772 Get streamdetails for a track or audiobook.
773
774 Delegates to the streaming handler for proper multi-file support.
775
776 Args:
777 item_id: Provider-specific item identifier
778 media_type: The type of media being requested
779
780 Returns:
781 StreamDetails object configured for the specific item type
782
783 Raises:
784 MediaNotFoundError: If no audio files are found for the item
785 """
786 return await self.streaming.get_stream_details(item_id, media_type)
787
788 async def _calculate_audiobook_duration_and_chapters(
789 self, item_id: str
790 ) -> tuple[int, list[MediaItemChapter]]:
791 """Calculate duration and chapters for audiobooks."""
792 audio_files = await self._get_audio_files(item_id)
793 total_duration = 0
794 chapters = []
795 current_position = 0.0
796
797 for i, file_info in enumerate(audio_files, 1):
798 chapter_duration = parse_duration(file_info.get("length", "0")) or 0
799 total_duration += chapter_duration
800
801 chapter_name = file_info.get("title") or file_info.get("name", f"Chapter {i}")
802 chapter = MediaItemChapter(
803 position=i,
804 name=clean_text(chapter_name),
805 start=current_position,
806 end=current_position + chapter_duration if chapter_duration > 0 else None,
807 )
808 chapters.append(chapter)
809 current_position += chapter_duration
810
811 return total_duration, chapters
812
813 async def get_audio_stream(
814 self, streamdetails: StreamDetails, seek_position: int = 0
815 ) -> AsyncGenerator[bytes]:
816 """Get audio stream from Internet Archive."""
817 # Use sock_read=None to allow long audiobook chapters to stream fully
818 timeout = aiohttp.ClientTimeout(sock_read=None, total=None)
819
820 if streamdetails.media_type == MediaType.AUDIOBOOK and isinstance(streamdetails.data, dict):
821 chapter_urls = streamdetails.data.get("chapters", [])
822 chapters_data = streamdetails.data.get("chapters_data", [])
823
824 # Calculate which chapter to start from based on seek_position
825 seek_position_ms = seek_position * 1000
826 start_chapter = 0
827
828 if seek_position > 0 and chapters_data:
829 accumulated_duration_ms = 0
830
831 for i, chapter_data in enumerate(chapters_data):
832 chapter_duration_ms = (
833 parse_duration(chapter_data.get("length", "0")) or 0
834 ) * 1000
835
836 if accumulated_duration_ms + chapter_duration_ms > seek_position_ms:
837 start_chapter = i
838 break
839 accumulated_duration_ms += chapter_duration_ms
840
841 # Stream chapters starting from calculated position
842 chapters_yielded = False
843 for i in range(start_chapter, len(chapter_urls)):
844 chapter_url = chapter_urls[i]
845
846 try:
847 async with self.mass.http_session.get(chapter_url, timeout=timeout) as response:
848 response.raise_for_status()
849 async for chunk in response.content.iter_chunked(8192):
850 chapters_yielded = True
851 yield chunk
852 except Exception as e:
853 self.logger.error(f"Chapter {i + 1} streaming failed: {e}")
854 continue
855
856 # If no chapters succeeded, raise an error instead of silent failure
857 if not chapters_yielded:
858 raise MediaNotFoundError(
859 f"Failed to stream any chapters for audiobook {streamdetails.item_id}"
860 )
861
862 else:
863 # Handle single files
864 audio_files = await self._get_audio_files(streamdetails.item_id)
865 if audio_files:
866 download_url = self.client.get_download_url(
867 streamdetails.item_id, audio_files[0]["name"]
868 )
869 async with self.mass.http_session.get(download_url, timeout=timeout) as response:
870 response.raise_for_status()
871 async for chunk in response.content.iter_chunked(8192):
872 yield chunk
873
874 @use_cache(expiration=86400 * 7) # Cache for 1 week
875 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
876 """Get full podcast details by id."""
877 metadata = await self._get_metadata(prov_podcast_id)
878 item_metadata = metadata.get("metadata", {})
879
880 title = clean_text(item_metadata.get("title"))
881 creator = clean_text(item_metadata.get("creator"))
882
883 if not title:
884 raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found or invalid")
885
886 podcast = Podcast(
887 item_id=prov_podcast_id,
888 provider=self.instance_id,
889 name=title,
890 provider_mappings={
891 create_provider_mapping(
892 prov_podcast_id, self.domain, self.instance_id, self.client.get_item_url
893 )
894 },
895 )
896
897 # Add publisher/creator
898 if creator:
899 podcast.publisher = creator
900
901 # Add metadata
902 if description := clean_text(item_metadata.get("description")):
903 podcast.metadata.description = description
904
905 # Add thumbnail
906 add_item_image(podcast, prov_podcast_id, self.instance_id)
907
908 # Calculate total episodes
909 try:
910 audio_files = await self._get_audio_files(prov_podcast_id)
911 podcast.total_episodes = len(audio_files)
912 except Exception as err:
913 self.logger.warning(f"Could not get episode count for podcast {prov_podcast_id}: {err}")
914 podcast.total_episodes = None
915
916 return podcast
917
918 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
919 """Get podcast episodes for given podcast id."""
920 metadata = await self._get_metadata(prov_podcast_id)
921 item_metadata = metadata.get("metadata", {})
922 audio_files = await self._get_audio_files(prov_podcast_id)
923
924 # Create podcast reference for episodes
925 podcast = Podcast(
926 item_id=prov_podcast_id,
927 provider=self.instance_id,
928 name=clean_text(item_metadata.get("title", prov_podcast_id)),
929 provider_mappings={
930 create_provider_mapping(
931 prov_podcast_id, self.domain, self.instance_id, self.client.get_item_url
932 )
933 },
934 )
935
936 for i, file_info in enumerate(audio_files, 1):
937 filename = file_info.get("name", "")
938
939 # Use file's title if available, otherwise clean up filename
940 episode_name = file_info.get("title", filename)
941 if not episode_name or episode_name == filename:
942 episode_name = filename.rsplit(".", 1)[0] if "." in filename else filename
943
944 # Try to extract episode number from file metadata first, then filename
945 episode_number = self._extract_track_number(file_info, episode_name, i)
946
947 episode = PodcastEpisode(
948 item_id=f"{prov_podcast_id}#{filename}",
949 provider=self.instance_id,
950 name=episode_name,
951 position=episode_number,
952 podcast=podcast,
953 provider_mappings={
954 ProviderMapping(
955 item_id=f"{prov_podcast_id}#{filename}",
956 provider_domain=self.domain,
957 provider_instance=self.instance_id,
958 url=self.client.get_download_url(prov_podcast_id, filename),
959 available=True,
960 )
961 },
962 )
963
964 # Add duration if available
965 if duration_str := file_info.get("length"):
966 if duration := parse_duration(duration_str):
967 episode.duration = duration
968
969 # Add episode metadata
970 if description := file_info.get("description"):
971 episode.metadata.description = clean_text(description)
972
973 yield episode
974
975 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
976 """Get single podcast episode by id."""
977 if "#" not in prov_episode_id:
978 raise MediaNotFoundError(f"Invalid episode ID format: {prov_episode_id}")
979
980 podcast_id, _ = prov_episode_id.split("#", 1)
981
982 async for episode in self.get_podcast_episodes(podcast_id):
983 if episode.item_id == prov_episode_id:
984 return episode
985
986 raise MediaNotFoundError(f"Episode {prov_episode_id} not found")
987