/
/
1"""
2Podcast RSS Feed Music Provider for Music Assistant.
3
4A URL to a podcast feed can be configured. The contents of that specific podcast
5feed will be forwarded to music assistant. In order to have multiple podcast feeds,
6multiple instances with each one feed must exist.
7
8"""
9
10from __future__ import annotations
11
12from collections.abc import AsyncGenerator
13from typing import TYPE_CHECKING, Any
14
15import podcastparser
16from aiohttp.client_exceptions import ClientError
17from music_assistant_models.enums import (
18 ContentType,
19 MediaType,
20 ProviderFeature,
21 StreamType,
22)
23from music_assistant_models.errors import InvalidProviderURI, MediaNotFoundError
24from music_assistant_models.helpers import create_safe_string
25from music_assistant_models.media_items import (
26 AudioFormat,
27 MediaItemImage,
28 Podcast,
29 PodcastEpisode,
30 UniqueList,
31)
32from music_assistant_models.streamdetails import StreamDetails
33
34from music_assistant.controllers.cache import use_cache
35from music_assistant.helpers.podcast_parsers import (
36 enrich_episode_chapters,
37 get_cached_podcast,
38 get_stream_url_from_episode,
39 parse_podcast,
40 parse_podcast_episode,
41 refresh_cached_podcast,
42)
43from music_assistant.models.music_provider import MusicProvider
44
45if TYPE_CHECKING:
46 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
47 from music_assistant_models.provider import ProviderManifest
48
49 from music_assistant.mass import MusicAssistant
50 from music_assistant.models import ProviderInstanceType
51
52CONF_FEED_URL = "feed_url"
53
54SUPPORTED_FEATURES = {
55 ProviderFeature.BROWSE,
56 ProviderFeature.LIBRARY_PODCASTS,
57}
58
59
60async def setup(
61 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
62) -> ProviderInstanceType:
63 """Initialize provider(instance) with given configuration."""
64 return PodcastMusicprovider(mass, manifest, config, SUPPORTED_FEATURES)
65
66
67class PodcastMusicprovider(MusicProvider):
68 """Podcast RSS Feed Music Provider."""
69
70 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
71 """Return Config entries to configure this provider."""
72 return ()
73
74 async def handle_async_init(self) -> None:
75 """Handle async initialization of the provider."""
76 feed_url = self.get_setup_value(CONF_FEED_URL)
77 if not feed_url:
78 msg = "No podcast feed set"
79 raise InvalidProviderURI(msg)
80 self.feed_url = podcastparser.normalize_feed_url(str(feed_url))
81 if self.feed_url is None:
82 raise MediaNotFoundError("The specified feed url cannot be used.")
83
84 self.podcast_id = create_safe_string(self.feed_url.replace("http", ""))
85
86 try:
87 self.parsed_podcast: dict[str, Any] = await self._cache_get_podcast()
88 except ClientError as exc:
89 raise MediaNotFoundError("Invalid URL") from exc
90
91 @property
92 def is_streaming_provider(self) -> bool:
93 """
94 Return True if the provider is a streaming provider.
95
96 This literally means that the catalog is not the same as the library contents.
97 For local based providers (files, plex), the catalog is the same as the library content.
98 It also means that data is if this provider is NOT a streaming provider,
99 data cross instances is unique, the catalog and library differs per instance.
100
101 Setting this to True will only query one instance of the provider for search and lookups.
102 Setting this to False will query all instances of this provider for search and lookups.
103 """
104 return False
105
106 @property
107 def instance_name_postfix(self) -> str | None:
108 """Return a (default) instance name postfix for this provider instance."""
109 return self.parsed_podcast.get("title")
110
111 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
112 """Retrieve library/subscribed podcasts from the provider."""
113 """
114 Only one podcast per rss feed is supported. The data format of the rss feed supports
115 only one podcast.
116 """
117 # on sync we renew
118 assert self.feed_url is not None
119 self.parsed_podcast = await refresh_cached_podcast(
120 mass=self.mass,
121 provider_instance_id=self.instance_id,
122 feed_url=self.feed_url,
123 )
124 yield await self._parse_podcast()
125
126 @use_cache(3600 * 24 * 7) # Cache for 7 days
127 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
128 """Get full artist details by id."""
129 if prov_podcast_id != self.podcast_id:
130 raise MediaNotFoundError(f"Podcast id not in provider: {prov_podcast_id}")
131 return await self._parse_podcast()
132
133 @use_cache(3600) # Cache for 1 hour
134 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
135 """Get (full) podcast episode details by id."""
136 for idx, episode in enumerate(self.parsed_podcast["episodes"]):
137 if prov_episode_id == episode["guid"]:
138 if mass_episode := self._parse_episode(episode, idx):
139 await enrich_episode_chapters(
140 session=self.mass.http_session,
141 chapters_json_url=episode.get("chapters_json_url"),
142 mass_episode=mass_episode,
143 )
144 return mass_episode
145 raise MediaNotFoundError("Episode not found")
146
147 async def get_podcast_episodes(
148 self,
149 prov_podcast_id: str,
150 ) -> AsyncGenerator[PodcastEpisode]:
151 """List all episodes for the podcast."""
152 if prov_podcast_id != self.podcast_id:
153 raise MediaNotFoundError(f"Podcast id not in provider: {prov_podcast_id}")
154 # sort episodes by published date
155 episodes: list[dict[str, Any]] = self.parsed_podcast["episodes"]
156 if episodes and episodes[0].get("published", 0) != 0:
157 episodes.sort(key=lambda x: x.get("published", 0))
158 for idx, episode in enumerate(episodes):
159 if mass_episode := self._parse_episode(episode, idx):
160 yield mass_episode
161
162 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
163 """Get streamdetails for a track/radio."""
164 for episode in self.parsed_podcast["episodes"]:
165 if item_id == episode["guid"]:
166 stream_url = get_stream_url_from_episode(episode=episode)
167 if stream_url is None:
168 raise MediaNotFoundError(f"Episode {item_id} has no playable stream")
169 return StreamDetails(
170 provider=self.instance_id,
171 item_id=item_id,
172 audio_format=AudioFormat(
173 content_type=ContentType.try_parse(stream_url),
174 ),
175 media_type=MediaType.PODCAST_EPISODE,
176 stream_type=StreamType.HTTP,
177 path=stream_url,
178 can_seek=True,
179 allow_seek=True,
180 extra_input_args=[
181 "-user_agent",
182 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
183 ],
184 )
185 raise MediaNotFoundError("Stream not found")
186
187 async def resolve_image(self, path: str) -> str | bytes:
188 """Resolve image for RSS provider with fallback to podcast cover."""
189 if not path.startswith("http"):
190 return path
191
192 try:
193 async with self.mass.http_session.get(path, raise_for_status=True) as response:
194 # Check if we got actual image content
195 content_type = response.headers.get("content-type", "").lower()
196 if not content_type.startswith(("image/", "application/octet-stream")):
197 # Not an image - likely redirected to error page
198 raise ClientError(f"Invalid content type: {content_type}")
199
200 return await response.read()
201
202 except ClientError, Exception:
203 # Try podcast cover fallback
204 podcast_cover = self.parsed_podcast.get("cover_url")
205 if podcast_cover and isinstance(podcast_cover, str) and podcast_cover != path:
206 async with self.mass.http_session.get(
207 podcast_cover, raise_for_status=True
208 ) as response:
209 return await response.read()
210
211 raise MediaNotFoundError(f"Episode image not found: {path}")
212
213 async def _parse_podcast(self) -> Podcast:
214 """Parse podcast information from podcast feed."""
215 assert self.feed_url is not None
216 return parse_podcast(
217 feed_url=self.feed_url,
218 parsed_feed=self.parsed_podcast,
219 instance_id=self.instance_id,
220 domain=self.domain,
221 mass_item_id=self.podcast_id,
222 )
223
224 def _parse_episode(
225 self, episode_obj: dict[str, Any], fallback_position: int
226 ) -> PodcastEpisode | None:
227 episode_result = parse_podcast_episode(
228 episode=episode_obj,
229 prov_podcast_id=self.podcast_id,
230 episode_cnt=fallback_position,
231 podcast_cover=self.parsed_podcast.get("cover_url"),
232 podcast_name=self.parsed_podcast.get("title"),
233 instance_id=self.instance_id,
234 domain=self.domain,
235 mass_item_id=episode_obj["guid"],
236 )
237 # Override remotely_accessible as these providers can have unreliable image URLs
238 if episode_result and episode_result.metadata.images:
239 new_images = []
240 for img in episode_result.metadata.images:
241 new_images.append(
242 MediaItemImage(
243 type=img.type,
244 path=img.path,
245 provider=img.provider,
246 remotely_accessible=False, # Force through imageproxy
247 )
248 )
249 episode_result.metadata.images = UniqueList(new_images)
250
251 return episode_result
252
253 async def _cache_get_podcast(self) -> dict[str, Any]:
254 assert self.feed_url is not None
255 return await get_cached_podcast(
256 mass=self.mass,
257 provider_instance_id=self.instance_id,
258 feed_url=self.feed_url,
259 )
260