Subsonic: Add Podcast Support (#1802)
authorEric Munson <eric@munsonfam.org>
Thu, 26 Dec 2024 13:01:14 +0000 (08:01 -0500)
committerGitHub <noreply@github.com>
Thu, 26 Dec 2024 13:01:14 +0000 (14:01 +0100)
music_assistant/controllers/music.py
music_assistant/models/music_provider.py
music_assistant/providers/opensubsonic/sonic_provider.py

index acb5a0f720124a0fa68102126962eebf0f118b9a..096bbcf34a06c8b050aa21425cc9e7a20a8d5de7 100644 (file)
@@ -78,7 +78,7 @@ DEFAULT_SYNC_INTERVAL = 3 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
 CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
-DB_SCHEMA_VERSION: Final[int] = 9
+DB_SCHEMA_VERSION: Final[int] = 10
 
 
 class MusicController(CoreController):
@@ -1162,6 +1162,15 @@ class MusicController(CoreController):
                 )
             await self.database.execute("DROP TABLE IF EXISTS track_loudness")
 
+        if prev_version <= 9:
+            try:
+                await self.database.execute(
+                    f"ALTER TABLE {DB_TABLE_PODCASTS} ADD COLUMN version TEXT"
+                )
+            except Exception as err:
+                if "duplicate column" not in str(err):
+                    raise
+
         # save changes
         await self.database.commit()
 
@@ -1300,6 +1309,7 @@ class MusicController(CoreController):
             [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
             [name] TEXT NOT NULL,
             [sort_name] TEXT NOT NULL,
+            [version] TEXT,
             [favorite] BOOLEAN DEFAULT 0,
             [publisher] TEXT NOT NULL,
             [total_episodes] INTEGER NOT NULL,
index fed41814b829b656b10c54615656f9fb55da0c91..ccdb3ce07c4c3f42d4d0fd4d0ce04f96609c8763 100644 (file)
@@ -18,6 +18,7 @@ from music_assistant_models.media_items import (
     ItemMapping,
     MediaItemType,
     Playlist,
+    Podcast,
     Radio,
     SearchResults,
     Track,
@@ -117,7 +118,7 @@ class MusicProvider(Provider):
             raise NotImplementedError
         yield  # type: ignore
 
-    async def get_library_podcasts(self) -> AsyncGenerator[Audiobook, None]:
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
         """Retrieve library/subscribed podcasts from the provider."""
         if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
             raise NotImplementedError
@@ -164,7 +165,7 @@ class MusicProvider(Provider):
         if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
             raise NotImplementedError
 
-    async def get_podcast(self, prov_podcast_id: str) -> Audiobook:  # type: ignore[return]
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:  # type: ignore[return]
         """Get full audiobook details by id."""
         if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
             raise NotImplementedError
@@ -174,7 +175,7 @@ class MusicProvider(Provider):
         if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features:
             raise NotImplementedError
 
-    async def get_episode(self, prov_episode_id: str) -> Chapter:  # type: ignore[return]
+    async def get_episode(self, prov_episode_id: str) -> Episode:  # type: ignore[return]
         """Get (full) podcast episode details by id."""
         if ProviderFeature.LIBRARY_PODCASTS in self.supported_features:
             raise NotImplementedError
index 0d0e601d82512a946600b1876da4bb95425b0119..84f370fb901432cc87735086b0d69e3b13a60637 100644 (file)
@@ -25,9 +25,11 @@ from music_assistant_models.media_items import (
     Album,
     Artist,
     AudioFormat,
+    Episode,
     ItemMapping,
     MediaItemImage,
     Playlist,
+    Podcast,
     ProviderMapping,
     SearchResults,
     Track,
@@ -51,6 +53,8 @@ if TYPE_CHECKING:
     from libopensonic.media import Artist as SonicArtist
     from libopensonic.media import ArtistInfo as SonicArtistInfo
     from libopensonic.media import Playlist as SonicPlaylist
+    from libopensonic.media import PodcastChannel as SonicPodcast
+    from libopensonic.media import PodcastEpisode as SonicEpisode
     from libopensonic.media import Song as SonicSong
 
 CONF_BASE_URL = "baseURL"
@@ -66,6 +70,12 @@ UNKNOWN_ARTIST_ID = "fake_artist_unknown"
 NAVI_VARIOUS_PREFIX = "MA-NAVIDROME-"
 
 
+# Because of some subsonic API weirdness, we have to lookup any podcast episode by finding it in
+# the list of episodes in a channel, to facilitate, we will use both the episode id and the
+# channel id concatenated as an episode id to MA
+EP_CHAN_SEP = "$!$"
+
+
 class OpenSonicProvider(MusicProvider):
     """Provider for Open Subsonic servers."""
 
@@ -130,6 +140,8 @@ class OpenSonicProvider(MusicProvider):
             ProviderFeature.SIMILAR_TRACKS,
             ProviderFeature.PLAYLIST_TRACKS_EDIT,
             ProviderFeature.PLAYLIST_CREATE,
+            ProviderFeature.LIBRARY_PODCASTS,
+            ProviderFeature.LIBRARY_PODCASTS_EDIT,
         }
 
     @property
@@ -392,6 +404,66 @@ class OpenSonicProvider(MusicProvider):
             ]
         return playlist
 
+    def _parse_podcast(self, sonic_podcast: SonicPodcast) -> Podcast:
+        podcast = Podcast(
+            item_id=sonic_podcast.id,
+            provider=self.domain,
+            name=sonic_podcast.title,
+            uri=sonic_podcast.url,
+            total_episodes=len(sonic_podcast.episodes),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=sonic_podcast.id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+        )
+
+        podcast.metadata.description = sonic_podcast.description
+        podcast.metadata.images = []
+
+        if sonic_podcast.cover_id:
+            podcast.metadata.images.append(
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=sonic_podcast.cover_id,
+                    provider=self.instance_id,
+                    remotely_accessible=False,
+                )
+            )
+
+        return podcast
+
+    def _parse_epsiode(self, sonic_episode: SonicEpisode, sonic_channel: SonicPodcast) -> Episode:
+        eid = f"{sonic_episode.channel_id}{EP_CHAN_SEP}{sonic_episode.id}"
+        pos = 1
+        for ep in sonic_channel.episodes:
+            if ep.id == sonic_episode.id:
+                break
+            pos += 1
+
+        episode = Episode(
+            item_id=eid,
+            provider=self.domain,
+            name=sonic_episode.title,
+            position=pos,
+            podcast=self._parse_podcast(sonic_channel),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=sonic_episode.id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+            duration=sonic_episode.duration,
+        )
+
+        if sonic_episode.description:
+            episode.metadata.description = sonic_episode.description
+
+        return episode
+
     async def _run_async(self, call: Callable, *args, **kwargs):
         return await self.mass.create_task(call, *args, **kwargs)
 
@@ -607,6 +679,64 @@ class OpenSonicProvider(MusicProvider):
             raise MediaNotFoundError(msg) from e
         return self._parse_playlist(sonic_playlist)
 
+    async def get_podcast_episodes(
+        self,
+        prov_podcast_id: str,
+    ) -> list[Episode]:
+        """Get all Episodes for given podcast id."""
+        if not self._enable_podcasts:
+            return []
+
+        channels = await self._run_async(
+            self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+        )
+
+        channel = channels[0]
+        episodes = []
+        pos = 1
+        for episode in channel.episodes:
+            episodes.append(self._parse_epsiode(episode, channel))
+            pos += 1
+        return episodes
+
+    async def get_episode(self, prov_episode_id: str) -> Episode:
+        """Get (full) podcast episode details by id."""
+        if not self._enable_podcasts:
+            return None
+        if EP_CHAN_SEP not in prov_episode_id:
+            return None
+
+        eid, chan_id = prov_episode_id.split(EP_CHAN_SEP)
+        channels = await self._run_async(self._conn.getPodcasts, incEpisodes=True, pid=chan_id)
+
+        sonic_podcast = channels[0]
+        sonic_episode = None
+        for ep in sonic_podcast.episodes:
+            if ep.id == eid:
+                sonic_episode = ep
+                break
+
+        return self._parse_epsiode(sonic_episode, sonic_podcast)
+
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
+        """Get full Podcast details by id."""
+        if not self._enable_podcasts:
+            return None
+
+        channels = await self._run_async(
+            self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+        )
+
+        return self._parse_podcast(channels[0])
+
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
+        """Retrieve library/subscribed podcasts from the provider."""
+        if self._enable_podcasts:
+            channels = await self._run_async(self._conn.getPodcasts, incEpisodes=True)
+
+            for channel in channels:
+                yield self._parse_podcast(channel)
+
     async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
         result: list[Track] = []