Finish Podcast support (#1828)
authorEric Munson <eric@munsonfam.org>
Sun, 5 Jan 2025 10:47:37 +0000 (05:47 -0500)
committerGitHub <noreply@github.com>
Sun, 5 Jan 2025 10:47:37 +0000 (11:47 +0100)
* fix: MusicController: Drop NOT NULL from publisher

The publisher column for Podcasts and Audio Books is allowed to be NULL,
update the database schema to allow this.

Signed-off-by: Eric B Munson <eric@munsonfam.org>
* feat: Subsonic: Finish podcast wireup

We still needed some changes to fetching stream details and the audio
stream itself to account for podcasts.

Signed-off-by: Eric B Munson <eric@munsonfam.org>
---------

Signed-off-by: Eric B Munson <eric@munsonfam.org>
music_assistant/controllers/music.py
music_assistant/providers/opensubsonic/sonic_provider.py

index ab49e9ee1b3076d2828dea7cef241aa0d5eac11e..a3be24aa5b4fa7ededfd5fe40a7bdff672d07922 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] = 11
+DB_SCHEMA_VERSION: Final[int] = 12
 
 
 class MusicController(CoreController):
@@ -1219,6 +1219,14 @@ class MusicController(CoreController):
                 if "duplicate column" not in str(err):
                     raise
 
+        if prev_version <= 11:
+            # Need to drop the NOT NULL requirement on podcasts.publisher and audiobooks.publisher
+            # However, because there is no ALTER COLUMN support in sqlite, we will need
+            # to create the tables again.
+            await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_AUDIOBOOKS}")
+            await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PODCASTS}")
+            await self.__create_database_tables()
+
         # save changes
         await self.database.commit()
 
@@ -1342,7 +1350,7 @@ class MusicController(CoreController):
             [sort_name] TEXT NOT NULL,
             [version] TEXT,
             [favorite] BOOLEAN DEFAULT 0,
-            [publisher] TEXT NOT NULL,
+            [publisher] TEXT,
             [total_chapters] INTEGER,
             [authors] json NOT NULL,
             [narrators] json NOT NULL,
@@ -1362,7 +1370,7 @@ class MusicController(CoreController):
             [sort_name] TEXT NOT NULL,
             [version] TEXT,
             [favorite] BOOLEAN DEFAULT 0,
-            [publisher] TEXT NOT NULL,
+            [publisher] TEXT,
             [total_episodes] INTEGER,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
index 9fd2acdf40db6555a9296e7ea8fc84d964fb798a..ad47cc338e09ee9869c1b2be0efd9b7fa38b1db5 100644 (file)
@@ -20,7 +20,12 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import LoginFailed, MediaNotFoundError, ProviderPermissionDenied
+from music_assistant_models.errors import (
+    LoginFailed,
+    MediaNotFoundError,
+    ProviderPermissionDenied,
+    UnsupportedFeaturedException,
+)
 from music_assistant_models.media_items import (
     Album,
     Artist,
@@ -451,7 +456,7 @@ class OpenSonicProvider(MusicProvider):
             podcast=self._parse_podcast(sonic_channel),
             provider_mappings={
                 ProviderMapping(
-                    item_id=sonic_episode.id,
+                    item_id=eid,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                 )
@@ -464,6 +469,17 @@ class OpenSonicProvider(MusicProvider):
 
         return episode
 
+    async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
+        chan_id, ep_id = eid.split(EP_CHAN_SEP)
+        chan = await self._run_async(self._conn.getPodcasts, incEpisodes=True, pid=chan_id)
+
+        for episode in chan[0].episodes:
+            if episode.id == ep_id:
+                return episode
+
+        msg = f"Can't find episode {ep_id} in podcast {chan_id}"
+        raise MediaNotFoundError(msg)
+
     async def _run_async(self, call: Callable, *args, **kwargs):
         return await self.mass.create_task(call, *args, **kwargs)
 
@@ -693,10 +709,8 @@ class OpenSonicProvider(MusicProvider):
 
         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_podcast(self, prov_podcast_id: str) -> Podcast:
@@ -806,32 +820,46 @@ class OpenSonicProvider(MusicProvider):
         self, item_id: str, media_type: MediaType = MediaType.TRACK
     ) -> StreamDetails:
         """Get the details needed to process a specified track."""
-        try:
-            sonic_song: SonicSong = await self._run_async(self._conn.getSong, item_id)
-        except (ParameterError, DataNotFoundError) as e:
-            msg = f"Item {item_id} not found"
-            raise MediaNotFoundError(msg) from e
+        if media_type == MediaType.TRACK:
+            try:
+                item: SonicSong = await self._run_async(self._conn.getSong, item_id)
+            except (ParameterError, DataNotFoundError) as e:
+                msg = f"Item {item_id} not found"
+                raise MediaNotFoundError(msg) from e
+
+            self.logger.debug(
+                "Fetching stream details for id %s '%s' with format '%s'",
+                item.id,
+                item.title,
+                item.content_type,
+            )
 
-        self.mass.create_task(self._report_playback_started(item_id))
+            self.mass.create_task(self._report_playback_started(item_id))
+        elif media_type == MediaType.EPISODE:
+            item: SonicEpisode = await self._get_podcast_episode(item_id)
 
-        mime_type = sonic_song.content_type
-        if mime_type.endswith("mpeg"):
-            mime_type = sonic_song.suffix
+            self.logger.debug(
+                "Fetching stream details for podcast episode '%s' with format '%s'",
+                item.id,
+                item.content_type,
+            )
+            self.mass.create_task(self._report_playback_started(item.id))
+        else:
+            msg = f"Unsupported media type encountered '{media_type}'"
+            raise UnsupportedFeaturedException(msg)
 
-        self.logger.debug(
-            "Fetching stream details for id %s '%s' with format '%s'",
-            sonic_song.id,
-            sonic_song.title,
-            mime_type,
-        )
+        mime_type = item.content_type
+        if mime_type.endswith("mpeg"):
+            mime_type = item.suffix
 
         return StreamDetails(
-            item_id=sonic_song.id,
+            item_id=item.id,
             provider=self.instance_id,
             can_seek=self._seek_support,
+            media_type=media_type,
             audio_format=AudioFormat(content_type=ContentType.try_parse(mime_type)),
             stream_type=StreamType.CUSTOM,
-            duration=sonic_song.duration if sonic_song.duration is not None else 0,
+            duration=item.duration if item.duration else 0,
         )
 
     async def _report_playback_started(self, item_id: str) -> None:
@@ -854,15 +882,20 @@ class OpenSonicProvider(MusicProvider):
         self.logger.debug("Streaming %s", streamdetails.item_id)
 
         def _streamer() -> None:
-            with self._conn.stream(
-                streamdetails.item_id, timeOffset=seek_position, estimateContentLength=True
-            ) as stream:
-                for chunk in stream.iter_content(chunk_size=40960):
-                    asyncio.run_coroutine_threadsafe(
-                        audio_buffer.put(chunk), self.mass.loop
-                    ).result()
-            # send empty chunk when we're done
-            asyncio.run_coroutine_threadsafe(audio_buffer.put(b"EOF"), self.mass.loop).result()
+            self.logger.debug("starting stream of item '%s'", streamdetails.item_id)
+            try:
+                with self._conn.stream(
+                    streamdetails.item_id, timeOffset=seek_position, estimateContentLength=True
+                ) as stream:
+                    for chunk in stream.iter_content(chunk_size=40960):
+                        asyncio.run_coroutine_threadsafe(
+                            audio_buffer.put(chunk), self.mass.loop
+                        ).result()
+                # send empty chunk when we're done
+                asyncio.run_coroutine_threadsafe(audio_buffer.put(b"EOF"), self.mass.loop).result()
+            except DataNotFoundError as err:
+                msg = f"Item '{streamdetails.item_id}' not found"
+                raise MediaNotFoundError(msg) from err
 
         # fire up an executor thread to put the audio chunks (threadsafe) on the audio buffer
         streamer_task = self.mass.loop.run_in_executor(None, _streamer)