--- /dev/null
+"""
+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
--- /dev/null
+<?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>