/
/
/
1"""Podcastfeed -> Mass."""
2
3import logging
4from datetime import UTC, datetime
5from io import BytesIO
6from math import isfinite
7from typing import TYPE_CHECKING, Any
8
9import podcastparser
10from aiohttp.client import ClientError, ClientTimeout
11from music_assistant_models.enums import ContentType, ImageType, LinkType, MediaType
12from music_assistant_models.errors import MediaNotFoundError
13from music_assistant_models.media_items import (
14 AudioFormat,
15 ItemMapping,
16 MediaItemChapter,
17 MediaItemImage,
18 MediaItemLink,
19 Podcast,
20 PodcastEpisode,
21 ProviderMapping,
22 UniqueList,
23)
24
25if TYPE_CHECKING:
26 import aiohttp
27
28 from music_assistant.mass import MusicAssistant
29
30LOGGER = logging.getLogger(__name__)
31
32# best-effort enrichment must never stall episode resolution, so cap the chapter fetch
33_CHAPTERS_FETCH_TIMEOUT = ClientTimeout(total=10)
34
35# defaults for the parsed-feed cache shared by the podcast providers
36CACHE_CATEGORY_PODCAST_FEED = 0
37PODCAST_FEED_CACHE_EXPIRATION = 24 * 3600
38
39
40async def get_podcastparser_dict(
41 *, session: aiohttp.ClientSession, feed_url: str, max_episodes: int = 0
42) -> dict[str, Any]:
43 """
44 Get feed parsed by podcastparser by providing the url.
45
46 max_episodes = 0 does not limit the returned episodes.
47 """
48 feed_data: bytes | None = None
49 # without user agent, some feeds can not be retrieved
50 # https://github.com/music-assistant/support/issues/3596
51 # but, reports on discord show, that also the opposite may be true
52 for headers in [{"User-Agent": "Mozilla/5.0"}, {}]:
53 # raises ClientError on status failure
54 # ClientError is the base class of all possible Error, i.e. not authorized,
55 # url doesn't exist etc. The body is read inside the context manager, so a
56 # connection is never left open when a feed fails midway.
57 try:
58 async with session.get(feed_url, headers=headers, raise_for_status=True) as response:
59 feed_data = await response.read()
60 except ClientError:
61 continue
62 break
63 if feed_data is None:
64 # we did not get a single acceptable response
65 raise MediaNotFoundError(
66 f"Did not get acceptable response while trying to access {feed_url}."
67 )
68 feed_stream = BytesIO(feed_data)
69 try:
70 return podcastparser.parse(feed_url, feed_stream, max_episodes=max_episodes) # type: ignore[no-any-return]
71 except podcastparser.FeedParseError:
72 raise MediaNotFoundError(f"The url at {feed_url} returns invalid RSS data.")
73
74
75async def get_cached_podcast(
76 *,
77 mass: MusicAssistant,
78 provider_instance_id: str,
79 feed_url: str,
80 max_episodes: int = 0,
81 cache_category: int = CACHE_CATEGORY_PODCAST_FEED,
82 cache_expiration: int = PODCAST_FEED_CACHE_EXPIRATION,
83) -> dict[str, Any]:
84 """
85 Return a podcast's parsed feed, retrieving and caching it when not cached yet.
86
87 :param mass: The MusicAssistant instance holding the cache.
88 :param provider_instance_id: Provider instance the cache entry belongs to.
89 :param feed_url: The podcast's feed url, also used as the cache key.
90 :param max_episodes: Maximum number of episodes to parse, 0 for unlimited.
91 :param cache_category: Cache category to store the parsed feed under.
92 :param cache_expiration: Time in seconds the cached feed stays valid.
93 :raises MediaNotFoundError: If the feed could not be retrieved or parsed.
94 """
95 parsed_feed = await mass.cache.get(
96 key=feed_url,
97 provider=provider_instance_id,
98 category=cache_category,
99 default=None,
100 )
101 if parsed_feed is None:
102 return await refresh_cached_podcast(
103 mass=mass,
104 provider_instance_id=provider_instance_id,
105 feed_url=feed_url,
106 max_episodes=max_episodes,
107 cache_category=cache_category,
108 cache_expiration=cache_expiration,
109 )
110 # this is a dictionary from podcastparser
111 return parsed_feed # type: ignore[no-any-return]
112
113
114async def refresh_cached_podcast(
115 *,
116 mass: MusicAssistant,
117 provider_instance_id: str,
118 feed_url: str,
119 max_episodes: int = 0,
120 cache_category: int = CACHE_CATEGORY_PODCAST_FEED,
121 cache_expiration: int = PODCAST_FEED_CACHE_EXPIRATION,
122) -> dict[str, Any]:
123 """
124 Retrieve a podcast's feed and store it in the cache, replacing any cached copy.
125
126 Use this on the library sync path: the sync must always refresh the cached feed,
127 regardless of whether a (still valid) cache entry exists.
128
129 :param mass: The MusicAssistant instance holding the cache.
130 :param provider_instance_id: Provider instance the cache entry belongs to.
131 :param feed_url: The podcast's feed url, also used as the cache key.
132 :param max_episodes: Maximum number of episodes to parse, 0 for unlimited.
133 :param cache_category: Cache category to store the parsed feed under.
134 :param cache_expiration: Time in seconds the cached feed stays valid.
135 :raises MediaNotFoundError: If the feed could not be retrieved or parsed.
136 """
137 parsed_feed = await get_podcastparser_dict(
138 session=mass.http_session, feed_url=feed_url, max_episodes=max_episodes
139 )
140 await mass.cache.set(
141 key=feed_url,
142 provider=provider_instance_id,
143 category=cache_category,
144 data=parsed_feed,
145 expiration=cache_expiration,
146 )
147 return parsed_feed
148
149
150def parse_podcast(
151 *,
152 feed_url: str,
153 parsed_feed: dict[str, Any],
154 instance_id: str,
155 domain: str,
156 mass_item_id: str | None = None,
157) -> Podcast:
158 """
159 Podcast -> Mass Podcast.
160
161 The item_id is the feed url by default, or the optional mass_item_id instead.
162 """
163 publisher = parsed_feed.get("author") or parsed_feed.get("itunes_author", "NO_AUTHOR")
164 item_id = feed_url if mass_item_id is None else mass_item_id
165 mass_podcast = Podcast(
166 item_id=item_id,
167 name=parsed_feed.get("title", "NO_TITLE"),
168 publisher=publisher,
169 provider=instance_id,
170 uri=parsed_feed.get("link"),
171 provider_mappings={
172 ProviderMapping(
173 item_id=item_id,
174 provider_domain=domain,
175 provider_instance=instance_id,
176 )
177 },
178 )
179 genres: list[str] = []
180 if _genres := parsed_feed.get("itunes_categories"):
181 for _sub_genre in _genres:
182 if isinstance(_sub_genre, list):
183 genres.extend(x for x in _sub_genre if isinstance(x, str))
184 elif isinstance(_sub_genre, str):
185 genres.append(_sub_genre)
186
187 mass_podcast.metadata.genres = set(genres)
188 mass_podcast.metadata.description = parsed_feed.get("description", "")
189 mass_podcast.metadata.explicit = parsed_feed.get("explicit", False)
190 language = parsed_feed.get("language")
191 if language is not None:
192 mass_podcast.metadata.languages = UniqueList([language])
193 episodes = parsed_feed.get("episodes", [])
194 mass_podcast.total_episodes = len(episodes)
195 podcast_cover = parsed_feed.get("cover_url")
196 if podcast_cover is not None:
197 mass_podcast.metadata.images = UniqueList(
198 [
199 MediaItemImage(
200 type=ImageType.THUMB,
201 path=podcast_cover,
202 provider=instance_id,
203 remotely_accessible=True,
204 )
205 ]
206 )
207 return mass_podcast
208
209
210def get_stream_url_from_episode(*, episode: dict[str, Any]) -> str | None:
211 """
212 Give the url of the episode's playable enclosure, or None if the episode has none.
213
214 Prefers the first audio or video enclosure, falling back to any non-image one.
215
216 :param episode: A single episode dict as returned by podcastparser.
217 """
218 fallback: str | None = None
219 for enclosure in episode.get("enclosures", []):
220 url = enclosure.get("url")
221 if not url:
222 continue
223 mime_type = str(enclosure.get("mime_type") or "")
224 if mime_type.startswith(("audio/", "video/")):
225 return str(url)
226 # feeds do declare bogus mime types (e.g. type="file") for real audio, so anything
227 # that is not an image stays a candidate in case no audio enclosure is declared
228 if fallback is None and not mime_type.startswith("image/"):
229 fallback = str(url)
230 return fallback
231
232
233def get_stream_url_and_guid_from_episode(*, episode: dict[str, Any]) -> tuple[str, str | None]:
234 """Give episode's stream url and guid, if it exists."""
235 stream_url = get_stream_url_from_episode(episode=episode)
236 if stream_url is None:
237 raise ValueError("Episode has no playable enclosure")
238 guid = episode.get("guid")
239 if guid is not None:
240 # The media's item_id is {prov_podcast_id} {guid_or_stream_url}
241 # see parse_podcast_episode.
242 # However, the guid must not contain a space, otherwise it is invalid.
243 # We cannot check, if it is a proper guid (uuid.UUID4(...)), as some podcast feeds
244 # do not follow the standard.
245 guid = None if len(guid.split(" ")) > 1 else guid
246 return stream_url, guid
247
248
249def find_episode_stream_url(*, parsed_feed: dict[str, Any], guid_or_stream_url: str) -> str | None:
250 """
251 Return the stream url of the episode identified by the item_id's episode part.
252
253 :param parsed_feed: The podcastparser dict of the feed holding the episode.
254 :param guid_or_stream_url: Episode part of the item_id, see parse_podcast_episode.
255 """
256 for episode in parsed_feed.get("episodes", []):
257 try:
258 stream_url, guid = get_stream_url_and_guid_from_episode(episode=episode)
259 except ValueError:
260 # episode without a playable enclosure carries no stream; skip it instead of
261 # aborting the lookup for the (potentially later) requested episode
262 continue
263 # only a guid rejected as unusable (None) falls back to the stream url, so an
264 # empty guid resolves the same way it was turned into an item_id
265 if guid_or_stream_url == (stream_url if guid is None else guid):
266 return stream_url
267 return None
268
269
270def rank_episodes_by_date(dates: list[Any]) -> list[int]:
271 """
272 Return the position of every episode, in the order the provider lists them.
273
274 Positions run oldest to newest, so the newest episode has the highest one. Episodes
275 without a date rank as the oldest, and when none of them has one the provider is
276 assumed to list its episodes newest-first.
277
278 :param dates: The publication dates in listing order, None where an episode has none.
279 Any comparable type will do as long as the provider reports them all the same way.
280 """
281 total = len(dates)
282 dated = [idx for idx, date in enumerate(dates) if date is not None]
283 if not dated:
284 return [total - idx for idx in range(total)]
285 undated = [idx for idx, date in enumerate(dates) if date is None]
286 positions = [0] * total
287 for position, idx in enumerate(undated + sorted(dated, key=lambda idx: dates[idx]), 1):
288 positions[idx] = position
289 return positions
290
291
292def get_episode_positions(episodes: list[dict[str, Any]]) -> list[int]:
293 """
294 Return the position of every episode, in the order the feed lists them.
295
296 Positions run oldest to newest, so the newest episode has the highest one.
297
298 :param episodes: The episodes of a single feed, as parsed by podcastparser.
299 """
300 # a feed that numbers only part of its episodes, or restarts its numbering every
301 # season, mixes incompatible scales, so its numbers are only used when unique
302 numbered = all(isinstance(ep.get("number"), int) and ep["number"] > 0 for ep in episodes)
303 if numbered and len({ep.get("season") or 0 for ep in episodes}) == 1:
304 return [ep["number"] for ep in episodes]
305 # podcastparser lists serial feeds oldest-first and all others newest-first,
306 # so rank on the publication date instead of the feed order
307 return rank_episodes_by_date([ep.get("published") or None for ep in episodes])
308
309
310def parse_podcast_episode(
311 *,
312 episode: dict[str, Any],
313 prov_podcast_id: str,
314 position: int,
315 podcast_cover: str | None = None,
316 podcast_name: str | None = None,
317 instance_id: str,
318 domain: str,
319 mass_item_id: str | None = None,
320) -> PodcastEpisode | None:
321 """
322 Podcast Episode -> Mass Podcast Episode.
323
324 The item_id is {prov_podcast_id} {guid_or_stream_url} by default, or the optional mass_item_id
325 instead. The podcast_cover is used, if the episode should not have its own cover. The
326 podcast_name names the parent podcast reference (falls back to the episode title if unset).
327
328 The function returns None, if the episode enclosure is missing, i.e. there is no stream
329 information present.
330
331 :param position: The episode's listing position, oldest to newest.
332 """
333 episode_duration = episode.get("total_time", 0.0)
334 episode_title = episode.get("title", "NO_EPISODE_TITLE")
335 episode_cover = episode.get("episode_art_url", podcast_cover)
336
337 # this is unix epoch in s, and 0 if unknown
338 episode_published: int | None = episode.get("published")
339 if episode_published == 0:
340 episode_published = None
341
342 try:
343 stream_url, guid = get_stream_url_and_guid_from_episode(episode=episode)
344 except ValueError:
345 # we are missing the episode enclosure or stream information
346 return None
347 # We treat a guid as invalid if contains a space.
348 guid_or_stream_url = guid if guid is not None and len(guid.split(" ")) == 1 else stream_url
349
350 # Default episode id. A guid is preferred as identification.
351 episode_id = f"{prov_podcast_id} {guid_or_stream_url}" if mass_item_id is None else mass_item_id
352 mass_episode = PodcastEpisode(
353 item_id=episode_id,
354 provider=instance_id,
355 name=episode_title,
356 duration=int(episode_duration),
357 position=position,
358 podcast=ItemMapping(
359 item_id=prov_podcast_id,
360 provider=instance_id,
361 name=podcast_name or episode_title,
362 media_type=MediaType.PODCAST,
363 ),
364 provider_mappings={
365 ProviderMapping(
366 item_id=episode_id,
367 provider_domain=domain,
368 provider_instance=instance_id,
369 audio_format=AudioFormat(
370 content_type=ContentType.try_parse(stream_url),
371 ),
372 url=stream_url,
373 )
374 },
375 )
376 if episode_published is not None:
377 mass_episode.metadata.release_date = datetime.fromtimestamp(episode_published, tz=UTC)
378
379 # description (podcastparser normalizes this to plain text, defaulting to "")
380 if description := episode.get("description"):
381 mass_episode.metadata.description = description
382
383 # explicit flag (itunes:explicit); only set when the feed actually declared it
384 explicit = episode.get("explicit")
385 if explicit is not None:
386 mass_episode.metadata.explicit = bool(explicit)
387
388 # episode webpage (the item <link>)
389 if link := episode.get("link"):
390 mass_episode.metadata.links = {MediaItemLink(type=LinkType.WEBSITE, url=link)}
391
392 # hosts/guests (podcast:person), mapped to performer names
393 if performers := parse_podcast_persons(episode.get("persons")):
394 mass_episode.metadata.performers = set(performers)
395
396 # inline chapters (Podlove Simple Chapters, parsed by podcastparser)
397 if chapters := episode.get("chapters"):
398 _chapters: list[MediaItemChapter] = []
399 for chapter in chapters:
400 if not isinstance(chapter, dict):
401 continue
402 title = chapter.get("title")
403 start = chapter.get("start")
404 # start may legitimately be 0 (opening chapter), so test against None
405 if title and start is not None:
406 _chapters.append(
407 MediaItemChapter(position=len(_chapters) + 1, name=title, start=start)
408 )
409 if _chapters:
410 mass_episode.metadata.chapters = _chapters
411
412 # cover image
413 if episode_cover is not None:
414 mass_episode.metadata.images = UniqueList(
415 [
416 MediaItemImage(
417 type=ImageType.THUMB,
418 path=episode_cover,
419 provider=instance_id,
420 remotely_accessible=True,
421 )
422 ]
423 )
424
425 return mass_episode
426
427
428def parse_podcast_persons(persons: Any) -> list[str]:
429 """
430 Extract performer names from a Podcasting 2.0 ``podcast:person`` collection.
431
432 Accepts the persons list as produced by podcastparser (feeds) or by the Podcast
433 Index API. Returns the display names de-duplicated (case-insensitive) in feed
434 order; any non-list input yields no names, so callers can pass a raw
435 ``.get("persons")`` without guarding.
436
437 :param persons: The raw persons collection, or any value (non-lists yield []).
438 """
439 names: list[str] = []
440 seen: set[str] = set()
441 if not isinstance(persons, list):
442 return names
443 for person in persons:
444 name = person.get("name") if isinstance(person, dict) else person
445 if not isinstance(name, str) or not (name := name.strip()):
446 continue
447 key = name.casefold()
448 if key in seen:
449 continue
450 seen.add(key)
451 names.append(name)
452 return names
453
454
455def _coerce_seconds(value: Any) -> float | None:
456 """Coerce a chapter time value to float seconds, or None if not parseable."""
457 if value is None:
458 return None
459 try:
460 seconds = float(value)
461 except TypeError, ValueError:
462 return None
463 return seconds if isfinite(seconds) else None
464
465
466def parse_chapters_from_json(data: dict[str, Any]) -> list[MediaItemChapter]:
467 """
468 Parse a Podcasting 2.0 ``podcast:chapters`` JSON document into chapters.
469
470 Spec: https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#chapters
471 Entries without a usable ``startTime``/``title`` are skipped, as are entries
472 explicitly hidden from the table of contents (``toc: false``).
473 """
474 chapters: list[MediaItemChapter] = []
475 raw_chapters = data.get("chapters")
476 if not isinstance(raw_chapters, list):
477 return chapters
478 for raw_chapter in raw_chapters:
479 if not isinstance(raw_chapter, dict):
480 continue
481 if raw_chapter.get("toc") is False:
482 continue
483 title = raw_chapter.get("title")
484 start = _coerce_seconds(raw_chapter.get("startTime"))
485 if not isinstance(title, str) or not title or start is None:
486 continue
487 chapters.append(
488 MediaItemChapter(
489 position=len(chapters) + 1,
490 name=title,
491 start=start,
492 end=_coerce_seconds(raw_chapter.get("endTime")),
493 )
494 )
495 return chapters
496
497
498async def enrich_episode_chapters(
499 *,
500 session: aiohttp.ClientSession,
501 chapters_json_url: str | None,
502 mass_episode: PodcastEpisode,
503) -> None:
504 """
505 Attach ``podcast:chapters`` (external JSON) to an episode lacking inline chapters.
506
507 Transport-agnostic: the caller supplies the chapters JSON URL directly (e.g.
508 podcastparser's ``chapters_json_url`` or the Podcast Index API ``chaptersUrl``),
509 so any podcast provider can reuse it. Chapter data is supplementary: any
510 fetch/parse failure is logged and ignored so it can never break episode
511 resolution or playback. Intended for the single-episode path only, to avoid a
512 network request per episode when listing a whole podcast.
513
514 :param session: The aiohttp session used for the best-effort fetch.
515 :param chapters_json_url: The Podcasting 2.0 chapters JSON URL, or None to skip.
516 :param mass_episode: The episode to enrich; untouched if it already has chapters.
517 """
518 if mass_episode.metadata.chapters:
519 return
520 url = chapters_json_url
521 if not url:
522 return
523 # send a browser UA: some podcast hosts/CDNs reject non-browser agents
524 # (music-assistant/support#3596). Chapter data is supplementary, so unlike
525 # get_podcastparser_dict we make a single best-effort attempt with no UA-less retry.
526 # TimeoutError (raised on total-timeout) is not a ClientError, so catch it explicitly
527 # to keep this enrichment best-effort. The parse stays inside the try so a malformed
528 # document can never break episode resolution either.
529 try:
530 async with session.get(
531 url,
532 headers={"User-Agent": "Mozilla/5.0"},
533 raise_for_status=True,
534 timeout=_CHAPTERS_FETCH_TIMEOUT,
535 ) as response:
536 data = await response.json(content_type=None)
537 if isinstance(data, dict) and (chapters := parse_chapters_from_json(data)):
538 mass_episode.metadata.chapters = chapters
539 except (ClientError, TimeoutError, ValueError, TypeError) as err:
540 LOGGER.warning("Failed to fetch podcast chapters from %s: %s", url, err)
541