music-assistant-server

10.8 KBPY
__init__.py
10.8 KB286 lines • python
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