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