Add RSS Feed Podcast provider (#1817)
authorTimm <saeugetier@googlemail.com>
Sun, 5 Jan 2025 22:33:14 +0000 (23:33 +0100)
committerGitHub <noreply@github.com>
Sun, 5 Jan 2025 22:33:14 +0000 (23:33 +0100)
music_assistant/providers/podcastfeed/__init__.py [new file with mode: 0644]
music_assistant/providers/podcastfeed/icon.svg [new file with mode: 0644]
music_assistant/providers/podcastfeed/manifest.json [new file with mode: 0644]
pyproject.toml
requirements_all.txt

diff --git a/music_assistant/providers/podcastfeed/__init__.py b/music_assistant/providers/podcastfeed/__init__.py
new file mode 100644 (file)
index 0000000..821c724
--- /dev/null
@@ -0,0 +1,247 @@
+"""
+Podcast RSS Feed Music Provider for Music Assistant.
+
+A URL to a podcast feed can be configured. The contents of that specific podcast
+feed will be forwarded to music assistant. In order to have multiple podcast feeds,
+multiple instances with each one feed must exist.
+
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+from io import BytesIO
+from typing import TYPE_CHECKING
+
+import podcastparser
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    ContentType,
+    ImageType,
+    MediaType,
+    ProviderFeature,
+    StreamType,
+)
+from music_assistant_models.errors import InvalidProviderURI, MediaNotFoundError
+from music_assistant_models.media_items import (
+    AudioFormat,
+    Episode,
+    ItemMapping,
+    MediaItemImage,
+    Podcast,
+    ProviderMapping,
+)
+from music_assistant_models.streamdetails import StreamDetails
+
+from music_assistant.models.music_provider import MusicProvider
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+CONF_FEED_URL = "feed_url"
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    # ruff: noqa: ARG001
+    if not config.get_value(CONF_FEED_URL):
+        msg = "No podcast feed set"
+        return InvalidProviderURI(msg)
+    return PodcastMusicprovider(mass, manifest, config)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    return (
+        ConfigEntry(
+            key=CONF_FEED_URL,
+            type=ConfigEntryType.STRING,
+            default_value=[],
+            label="RSS Feed URL",
+            required=True,
+        ),
+    )
+
+
+class PodcastMusicprovider(MusicProvider):
+    """Podcast RSS Feed Music Provider."""
+
+    @property
+    def supported_features(self) -> set[ProviderFeature]:
+        """Return the features supported by this Provider."""
+        return {ProviderFeature.BROWSE, ProviderFeature.SEARCH, ProviderFeature.LIBRARY_PODCASTS}
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        # ruff: noqa: S310
+        feed_url = podcastparser.normalize_feed_url(self.config.get_value(CONF_FEED_URL))
+        self.podcast_id = hash(feed_url)
+        async with self.mass.http_session.get(feed_url) as response:
+            if response.status == 200:
+                feed_data = await response.read()
+                feed_stream = BytesIO(feed_data)
+                self.parsed = podcastparser.parse(feed_url, feed_stream)
+            else:
+                raise Exception(f"Failed to fetch RSS podcast feed: {response.status}")
+
+    @property
+    def is_streaming_provider(self) -> bool:
+        """
+        Return True if the provider is a streaming provider.
+
+        This literally means that the catalog is not the same as the library contents.
+        For local based providers (files, plex), the catalog is the same as the library content.
+        It also means that data is if this provider is NOT a streaming provider,
+        data cross instances is unique, the catalog and library differs per instance.
+
+        Setting this to True will only query one instance of the provider for search and lookups.
+        Setting this to False will query all instances of this provider for search and lookups.
+        """
+        return False
+
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
+        """Retrieve library/subscribed podcasts from the provider."""
+        """
+        Only one podcast per rss feed is supported. The data format of the rss feed supports
+        only one podcast.
+        """
+        yield await self._parse_podcast()
+
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
+        """Get full artist details by id."""
+        if prov_podcast_id in self.podcast_id:
+            return await self._parse_podcast()
+        else:
+            raise Exception(f"Podcast id not in provider: {prov_podcast_id}")
+
+    async def get_episode(self, prov_episode_id: str) -> Episode:
+        """Get (full) podcast episode details by id."""
+        for episode in self.parsed["episodes"]:
+            if prov_episode_id in episode["guid"]:
+                return await self._parse_episode(episode)
+        raise MediaNotFoundError("Track not found")
+
+    async def get_podcast_episodes(
+        self,
+        prov_podcast_id: str,
+    ) -> list[Episode]:
+        """List all episodes for the podcast."""
+        episodes = []
+
+        for episode in self.parsed["episodes"]:
+            episodes.append(await self._parse_episode(episode, prov_podcast_id))
+
+        return episodes
+
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
+        """Get streamdetails for a track/radio."""
+        for episode in self.parsed["episodes"]:
+            if item_id in episode["guid"]:
+                return StreamDetails(
+                    provider=self.instance_id,
+                    item_id=item_id,
+                    audio_format=AudioFormat(
+                        # hard coded to unknown, so ffmpeg figures out
+                        content_type=ContentType.UNKNOWN,
+                    ),
+                    media_type=MediaType.PODCAST,
+                    stream_type=StreamType.HTTP,
+                    path=episode["enclosures"][0]["url"],
+                )
+        raise MediaNotFoundError("Stream not found")
+
+    async def _parse_podcast(self) -> Podcast:
+        """Parse podcast information from podcast feed."""
+        podcast = Podcast(
+            item_id=self.podcast_id,
+            name=self.parsed["title"],
+            provider=self.domain,
+            uri=self.parsed["link"],
+            total_episodes=len(self.parsed["episodes"]),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=self.parsed["title"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+            publisher=self.parsed["itunes_author"],
+        )
+
+        podcast.metadata.description = self.parsed["description"]
+        if len(self.parsed["itunes_categories"]) > 0:
+            podcast.metadata.style = self.parsed["itunes_categories"][0]
+
+        if self.parsed["cover_url"]:
+            img_url = self.parsed["cover_url"]
+            podcast.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=img_url,
+                    provider=self.lookup_key,
+                    remotely_accessible=True,
+                )
+            ]
+
+        return podcast
+
+    async def _parse_episode(self, track_obj: dict, prov_podcast_id: str) -> Episode:
+        name = track_obj["title"]
+        track_id = track_obj["guid"]
+        episode = Episode(
+            item_id=track_id,
+            provider=self.domain,
+            name=name,
+            duration=track_obj["total_time"],
+            podcast=ItemMapping(
+                item_id=prov_podcast_id,
+                provider=self.instance_id,
+                name=self.parsed["title"],
+                media_type=MediaType.PODCAST,
+            ),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.MP3,
+                    ),
+                    url=track_obj["link"],
+                )
+            },
+        )
+
+        if "episode_art_url" in track_obj:
+            episode.metadata.images = [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=track_obj["episode_art_url"],
+                    provider=self.lookup_key,
+                    remotely_accessible=True,
+                )
+            ]
+        episode.metadata.description = track_obj["description"]
+        episode.metadata.explicit = track_obj["explicit"]
+
+        return episode
diff --git a/music_assistant/providers/podcastfeed/icon.svg b/music_assistant/providers/podcastfeed/icon.svg
new file mode 100644 (file)
index 0000000..18ef98b
--- /dev/null
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 25 25"
+   version="1.1"
+   id="svg485"
+   sodipodi:docname="icon.svg"
+   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs489">
+    <clipPath
+       clipPathUnits="userSpaceOnUse"
+       id="clipPath41">
+      <path
+         d="M 0,720 H 1280 V 0 H 0 Z"
+         id="path39" />
+    </clipPath>
+  </defs>
+  <sodipodi:namedview
+     id="namedview487"
+     pagecolor="#505050"
+     bordercolor="#eeeeee"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#505050"
+     showgrid="false"
+     inkscape:zoom="19.20685"
+     inkscape:cx="20.877968"
+     inkscape:cy="15.385136"
+     inkscape:window-width="1854"
+     inkscape:window-height="1011"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg485" />
+  <g
+     id="g35"
+     transform="matrix(0.0455566,0,0,-0.04555661,-16.636697,28.98029)">
+    <g
+       id="g37"
+       clip-path="url(#clipPath41)">
+      <g
+         id="g43"
+         transform="translate(913.9863,360)">
+        <path
+           d="m 0,0 c 0,-151.318 -122.668,-273.986 -273.986,-273.986 -151.319,0 -273.987,122.668 -273.987,273.986 0,151.318 122.668,273.986 273.987,273.986 C -122.668,273.986 0,151.318 0,0"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path45" />
+      </g>
+      <g
+         id="g47"
+         transform="translate(640,286.7363)">
+        <path
+           d="m 0,0 v 0 c -42.736,0 -77.703,34.966 -77.703,77.703 v 129.73 c 0,42.736 34.967,77.702 77.703,77.702 42.736,0 77.703,-34.966 77.703,-77.702 V 77.703 C 77.703,34.966 42.736,0 0,0"
+           style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path49" />
+      </g>
+      <g
+         id="g51"
+         transform="translate(586.7026,493.6016)">
+        <path
+           d="m 0,0 c 0.338,28.959 24.08,53.297 53.297,53.297 7.722,0 7.735,12 0,12 C 17.527,65.297 -11.586,35.452 -12,0 -12.09,-7.725 -0.09,-7.729 0,0"
+           style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path53" />
+      </g>
+      <g
+         id="g55"
+         transform="translate(731.0303,353.0205)">
+        <path
+           d="m 0,0 c -0.129,-16.896 -6.134,-34.063 -16.109,-46.977 -11.321,-14.655 -26.627,-25.055 -43.27,-30.599 -35.707,-11.895 -75.972,-2.81 -101.495,24.674 -3.881,4.179 -5.569,6.366 -8.473,10.867 -1.401,2.17 -2.712,4.397 -3.925,6.677 -0.607,1.139 -1.19,2.291 -1.748,3.455 0.019,-0.038 -1.595,3.517 -0.888,1.85 -4.463,10.519 -6.072,19.535 -6.153,30.053 -0.118,15.437 -24.118,15.472 -24,0 0.36,-46.89 33.396,-86.193 76.999,-100.719 43.436,-14.469 94.8,-2.392 125.726,31.807 C 13.825,-49.934 23.804,-25.665 24,0 24.118,15.447 0.118,15.462 0,0"
+           style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path57" />
+      </g>
+      <g
+         id="g59"
+         transform="translate(640,148.1289)">
+        <path
+           d="M 0,0 V 0 C 7.943,0 14.442,6.499 14.442,14.442 V 103.8 c 0,7.943 -6.499,14.443 -14.442,14.443 -7.943,0 -14.442,-6.5 -14.442,-14.443 V 14.442 C -14.442,6.499 -7.943,0 0,0"
+           style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path61" />
+      </g>
+      <g
+         id="g63"
+         transform="translate(711.8652,148.1289)">
+        <path
+           d="m 0,0 h -143.73 c -6.6,0 -12,5.399 -12,12 0,6.6 5.4,12 12,12 H 0 C 6.6,24 12,18.6 12,12 12,5.399 6.6,0 0,0"
+           style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+           id="path65" />
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/music_assistant/providers/podcastfeed/manifest.json b/music_assistant/providers/podcastfeed/manifest.json
new file mode 100644 (file)
index 0000000..47d9bb1
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "podcastfeed",
+  "name": "Podcast RSS Feed",
+  "description": "A parser for podcast rss feeds",
+  "codeowners": ["@saeugetier"],
+  "requirements": ["podcastparser==0.6.10"],
+  "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).",
+  "multi_instance": true
+}
index 77939520946e6216a5174a40a9cbd01fbcae12b8..985a4345d3a61357321234019f29491cf89cdc5f 100644 (file)
@@ -28,6 +28,7 @@ dependencies = [
   "music-assistant-models==1.1.7",
   "orjson==3.10.12",
   "pillow==11.0.0",
+  "podcastparser==0.6.10",
   "python-slugify==8.0.4",
   "unidecode==1.3.8",
   "xmltodict==0.14.2",
index 01e7b6a209c3fc359c3e22e6f9045e492287d35b..ddbfb11587525b46161606c4cb725752ecf390c1 100644 (file)
@@ -29,6 +29,7 @@ orjson==3.10.12
 pillow==11.0.0
 pkce==1.0.3
 plexapi==4.15.16
+podcastparser==0.6.10
 py-opensonic==5.2.1
 pyblu==2.0.0
 PyChromecast==14.0.5