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