Add podcast support to Spotify provider (#2349)
authorOzGav <gavnosp@hotmail.com>
Wed, 10 Sep 2025 18:33:56 +0000 (04:33 +1000)
committerGitHub <noreply@github.com>
Wed, 10 Sep 2025 18:33:56 +0000 (20:33 +0200)
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/constants.py
music_assistant/providers/spotify/helpers.py
music_assistant/providers/spotify/parsers.py
music_assistant/providers/spotify/provider.py
music_assistant/providers/spotify/streaming.py

index cb7e89eb7908b6863cfa8a02ef03a35e532d9e7f..cbba828e3b1aa008c3db77333dd5590fce4e6e7c 100644 (file)
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
 import pkce
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant_models.enums import ConfigEntryType
-from music_assistant_models.errors import SetupFailedError
+from music_assistant_models.errors import InvalidDataError, SetupFailedError
 
 from music_assistant.helpers.app_vars import app_var  # type: ignore[attr-defined]
 from music_assistant.helpers.auth import AuthenticationHelper
@@ -18,7 +18,9 @@ from .constants import (
     CONF_ACTION_AUTH,
     CONF_ACTION_CLEAR_AUTH,
     CONF_CLIENT_ID,
+    CONF_PLAYED_THRESHOLD,
     CONF_REFRESH_TOKEN,
+    CONF_SYNC_PLAYED_STATUS,
     SCOPE,
 )
 from .provider import SpotifyProvider
@@ -50,6 +52,9 @@ async def get_config_entries(
         # spotify PKCE auth flow
         # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
 
+        if values is None:
+            raise InvalidDataError("values cannot be None for authentication action")
+
         code_verifier, code_challenge = pkce.generate_pkce_pair()
         async with AuthenticationHelper(mass, cast("str", values["session_id"])) as auth_helper:
             params = {
@@ -81,12 +86,13 @@ async def get_config_entries(
 
     # handle action clear authentication
     if action == CONF_ACTION_CLEAR_AUTH:
-        assert values
+        if values is None:
+            raise InvalidDataError("values cannot be None for clear auth action")
         values[CONF_REFRESH_TOKEN] = None
 
-    auth_required = values.get(CONF_REFRESH_TOKEN) in (None, "")
+    auth_required = (values or {}).get(CONF_REFRESH_TOKEN) in (None, "")
 
-    if auth_required:
+    if auth_required and values is not None:
         values[CONF_CLIENT_ID] = None
         label_text = (
             "You need to authenticate to Spotify. Click the authenticate button below "
@@ -127,6 +133,27 @@ async def get_config_entries(
             value=values.get(CONF_CLIENT_ID) if values else None,
             hidden=not auth_required,
         ),
+        ConfigEntry(
+            key=CONF_SYNC_PLAYED_STATUS,
+            type=ConfigEntryType.BOOLEAN,
+            label="Sync Played Status from Spotify",
+            description="Automatically sync episode played status from Spotify to Music Assistant. "
+            "Episodes marked as played in Spotify will be marked as played in MA."
+            "Only enable this if you use both the Spotify app and Music Assistant "
+            "for podcast playback.",
+            default_value=False,
+            value=values.get(CONF_SYNC_PLAYED_STATUS, True) if values else True,
+        ),
+        ConfigEntry(
+            key=CONF_PLAYED_THRESHOLD,
+            type=ConfigEntryType.INTEGER,
+            label="Played Threshold (%)",
+            description="Percentage of episode completion to consider it 'played' "
+            "when not explicitly marked by Spotify (50 = 50%, 90 = 90%).",
+            default_value=90,
+            value=values.get(CONF_PLAYED_THRESHOLD, 90) if values else 90,
+            range=(1, 100),
+        ),
         ConfigEntry(
             key=CONF_ACTION_AUTH,
             type=ConfigEntryType.ACTION,
index 365cdc37a282a8a525a95cdddd3fba6d1dd2337c..dcfe2d4649f8a0d88b64e77d23201dfaaee873fa 100644 (file)
@@ -9,6 +9,9 @@ CONF_CLIENT_ID = "client_id"
 CONF_ACTION_AUTH = "auth"
 CONF_REFRESH_TOKEN = "refresh_token"
 CONF_ACTION_CLEAR_AUTH = "clear_auth"
+CONF_ENABLE_PODCASTS = "enable_podcasts"
+CONF_SYNC_PLAYED_STATUS = "sync_played_status"
+CONF_PLAYED_THRESHOLD = "played_threshold"
 
 # OAuth Settings
 SCOPE = [
@@ -56,4 +59,6 @@ SUPPORTED_FEATURES = {
     ProviderFeature.ARTIST_ALBUMS,
     ProviderFeature.ARTIST_TOPTRACKS,
     ProviderFeature.SIMILAR_TRACKS,
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_PODCASTS_EDIT,
 }
index e9b1aa258dc7ad0ba7c367941e8f38a282f89ddc..c8347abb5d40bd4b26b06fcb0ff61b9e57cb8d13 100644 (file)
@@ -16,6 +16,7 @@ async def get_librespot_binary() -> str:
             returncode, output = await check_output(librespot_path, "--version")
             if returncode == 0 and b"librespot" in output:
                 return librespot_path
+            return None
         except OSError:
             return None
 
index ca8f7ad5dadfe32a7fcd50e38ce6ac24ecb9d18d..2fcc040a7410d20d7c921bbcf30b781dce400faf 100644 (file)
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import contextlib
+from datetime import datetime
 from typing import TYPE_CHECKING, Any
 
 from music_assistant_models.enums import AlbumType, ContentType, ExternalID, ImageType
@@ -12,6 +13,8 @@ from music_assistant_models.media_items import (
     AudioFormat,
     MediaItemImage,
     Playlist,
+    Podcast,
+    PodcastEpisode,
     ProviderMapping,
     Track,
 )
@@ -23,6 +26,31 @@ if TYPE_CHECKING:
     from .provider import SpotifyProvider
 
 
+def parse_images(
+    images_list: list[dict[str, Any]], lookup_key: str, exclude_generic: bool = False
+) -> UniqueList[MediaItemImage]:
+    """Parse images list into MediaItemImage objects."""
+    if not images_list:
+        return UniqueList([])
+
+    for img in images_list:
+        img_url = img["url"]
+        # Skip generic placeholder images for artists if requested
+        if exclude_generic and "2a96cbd8b46e442fc41c2b86b821562f" in img_url:
+            continue
+        return UniqueList(
+            [
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=img_url,
+                    provider=lookup_key,
+                    remotely_accessible=True,
+                )
+            ]
+        )
+    return UniqueList([])
+
+
 def parse_artist(artist_obj: dict[str, Any], provider: SpotifyProvider) -> Artist:
     """Parse spotify artist object to generic layout."""
     artist = Artist(
@@ -40,21 +68,11 @@ def parse_artist(artist_obj: dict[str, Any], provider: SpotifyProvider) -> Artis
     )
     if "genres" in artist_obj:
         artist.metadata.genres = set(artist_obj["genres"])
-    if artist_obj.get("images"):
-        for img in artist_obj["images"]:
-            img_url = img["url"]
-            if "2a96cbd8b46e442fc41c2b86b821562f" not in img_url:
-                artist.metadata.images = UniqueList(
-                    [
-                        MediaItemImage(
-                            type=ImageType.THUMB,
-                            path=img_url,
-                            provider=provider.lookup_key,
-                            remotely_accessible=True,
-                        )
-                    ]
-                )
-                break
+
+    # Use unified image parsing with generic exclusion
+    artist.metadata.images = parse_images(
+        artist_obj.get("images", []), provider.lookup_key, exclude_generic=True
+    )
     return artist
 
 
@@ -91,17 +109,9 @@ def parse_album(album_obj: dict[str, Any], provider: SpotifyProvider) -> Album:
 
     if "genres" in album_obj:
         album.metadata.genres = set(album_obj["genres"])
-    if album_obj.get("images"):
-        album.metadata.images = UniqueList(
-            [
-                MediaItemImage(
-                    type=ImageType.THUMB,
-                    path=album_obj["images"][0]["url"],
-                    provider=provider.lookup_key,
-                    remotely_accessible=True,
-                )
-            ]
-        )
+
+    album.metadata.images = parse_images(album_obj.get("images", []), provider.lookup_key)
+
     if "label" in album_obj:
         album.metadata.label = album_obj["label"]
     if album_obj.get("release_date"):
@@ -131,10 +141,7 @@ def parse_track(
                 item_id=track_obj["id"],
                 provider_domain=provider.domain,
                 provider_instance=provider.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.OGG,
-                    bit_rate=320,
-                ),
+                audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
                 url=track_obj["external_urls"]["spotify"],
                 available=not track_obj["is_local"] and track_obj["is_playable"],
             )
@@ -159,17 +166,9 @@ def parse_track(
         track.metadata.preview = track_obj["preview_url"]
     if "album" in track_obj:
         track.album = parse_album(track_obj["album"], provider)
-        if track_obj["album"].get("images"):
-            track.metadata.images = UniqueList(
-                [
-                    MediaItemImage(
-                        type=ImageType.THUMB,
-                        path=track_obj["album"]["images"][0]["url"],
-                        provider=provider.lookup_key,
-                        remotely_accessible=True,
-                    )
-                ]
-            )
+        track.metadata.images = parse_images(
+            track_obj["album"].get("images", []), provider.lookup_key
+        )
     if track_obj.get("copyright"):
         track.metadata.copyright = track_obj["copyright"]
     if track_obj.get("explicit"):
@@ -182,13 +181,19 @@ def parse_track(
 def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> Playlist:
     """Parse spotify playlist object to generic layout."""
     is_editable = (
-        playlist_obj["owner"]["id"] == provider._sp_user["id"] or playlist_obj["collaborative"]
-    )
+        provider._sp_user is not None and playlist_obj["owner"]["id"] == provider._sp_user["id"]
+    ) or playlist_obj["collaborative"]
+
+    # Get owner name with fallback
+    owner_name = playlist_obj["owner"].get("display_name")
+    if owner_name is None and provider._sp_user is not None:
+        owner_name = provider._sp_user["display_name"]
+
     playlist = Playlist(
         item_id=playlist_obj["id"],
         provider=provider.instance_id if is_editable else provider.lookup_key,
         name=playlist_obj["name"],
-        owner=playlist_obj["owner"]["display_name"],
+        owner=owner_name,
         provider_mappings={
             ProviderMapping(
                 item_id=playlist_obj["id"],
@@ -199,18 +204,120 @@ def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> P
         },
         is_editable=is_editable,
     )
-    if playlist_obj.get("images"):
-        playlist.metadata.images = UniqueList(
-            [
-                MediaItemImage(
-                    type=ImageType.THUMB,
-                    path=playlist_obj["images"][0]["url"],
-                    provider=provider.lookup_key,
-                    remotely_accessible=True,
-                )
-            ]
-        )
-    if playlist.owner is None:
-        playlist.owner = provider._sp_user["display_name"]
+
+    playlist.metadata.images = parse_images(playlist_obj.get("images", []), provider.lookup_key)
     playlist.cache_checksum = str(playlist_obj["snapshot_id"])
     return playlist
+
+
+def parse_podcast(podcast_obj: dict[str, Any], provider: SpotifyProvider) -> Podcast:
+    """Parse spotify podcast (show) object to generic layout."""
+    podcast = Podcast(
+        item_id=podcast_obj["id"],
+        provider=provider.lookup_key,
+        name=podcast_obj["name"],
+        provider_mappings={
+            ProviderMapping(
+                item_id=podcast_obj["id"],
+                provider_domain=provider.domain,
+                provider_instance=provider.instance_id,
+                url=podcast_obj["external_urls"]["spotify"],
+            )
+        },
+        publisher=podcast_obj.get("publisher"),
+        total_episodes=podcast_obj.get("total_episodes"),
+    )
+
+    # Set metadata
+    if podcast_obj.get("description"):
+        podcast.metadata.description = podcast_obj["description"]
+
+    podcast.metadata.images = parse_images(podcast_obj.get("images", []), provider.lookup_key)
+
+    if "explicit" in podcast_obj:
+        podcast.metadata.explicit = podcast_obj["explicit"]
+
+    # Convert languages list to genres for categorization
+    if "languages" in podcast_obj:
+        podcast.metadata.genres = set(podcast_obj["languages"])
+
+    return podcast
+
+
+def parse_podcast_episode(
+    episode_obj: dict[str, Any], provider: SpotifyProvider, podcast: Podcast | None = None
+) -> PodcastEpisode:
+    """Parse spotify podcast episode object to generic layout."""
+    # Get or create a basic podcast reference if not provided
+    if podcast is None and "show" in episode_obj:
+        podcast = Podcast(
+            item_id=episode_obj["show"]["id"],
+            provider=provider.lookup_key,
+            name=episode_obj["show"]["name"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=episode_obj["show"]["id"],
+                    provider_domain=provider.domain,
+                    provider_instance=provider.instance_id,
+                    url=episode_obj["show"]["external_urls"]["spotify"],
+                )
+            },
+        )
+    elif podcast is None:
+        # Create a minimal podcast reference if none available
+        podcast = Podcast(
+            item_id="unknown",
+            provider=provider.lookup_key,
+            name="Unknown Podcast",
+            provider_mappings=set(),
+        )
+
+    episode = PodcastEpisode(
+        item_id=episode_obj["id"],
+        provider=provider.lookup_key,
+        name=episode_obj["name"],
+        duration=episode_obj["duration_ms"] // 1000 if episode_obj.get("duration_ms") else 0,
+        podcast=podcast,
+        position=0,
+        provider_mappings={
+            ProviderMapping(
+                item_id=episode_obj["id"],
+                provider_domain=provider.domain,
+                provider_instance=provider.instance_id,
+                audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=160),
+                url=episode_obj["external_urls"]["spotify"],
+            )
+        },
+    )
+
+    # Set description in metadata
+    if episode_obj.get("description"):
+        episode.metadata.description = episode_obj["description"]
+
+    # Add release date to metadata
+    if episode_obj.get("release_date"):
+        with contextlib.suppress(ValueError, TypeError):
+            date_str = episode_obj["release_date"].strip()
+
+            if len(date_str) == 4:
+                # Year only: "2023" -> "2023-01-01T00:00:00+00:00"
+                date_str = f"{date_str}-01-01T00:00:00+00:00"
+            elif len(date_str) == 10:
+                # Date only: "2023-12-25" -> "2023-12-25T00:00:00+00:00"
+                date_str = f"{date_str}T00:00:00+00:00"
+
+            episode.metadata.release_date = datetime.fromisoformat(date_str)
+
+    episode.metadata.images = parse_images(episode_obj.get("images", []), provider.lookup_key)
+
+    # Use podcast artwork if episode has none
+    if not episode.metadata.images and isinstance(podcast, Podcast) and podcast.metadata.images:
+        episode.metadata.images = podcast.metadata.images
+
+    if "explicit" in episode_obj:
+        episode.metadata.explicit = episode_obj["explicit"]
+
+    if "audio_preview_url" in episode_obj:
+        episode.metadata.preview = episode_obj["audio_preview_url"]
+
+    return episode
index 303201a435a0a93dabe0da136b7865b54c261bbc..1a54cb22e6c710af639f1adeec0e306c35f7f76b 100644 (file)
@@ -8,6 +8,7 @@ import time
 from collections.abc import AsyncGenerator
 from typing import TYPE_CHECKING, Any
 
+import aiohttp
 from music_assistant_models.enums import (
     ContentType,
     ImageType,
@@ -27,9 +28,12 @@ from music_assistant_models.media_items import (
     MediaItemImage,
     MediaItemType,
     Playlist,
+    Podcast,
+    PodcastEpisode,
     ProviderMapping,
     SearchResults,
     Track,
+    UniqueList,
 )
 from music_assistant_models.streamdetails import StreamDetails
 
@@ -42,26 +46,45 @@ from music_assistant.models.music_provider import MusicProvider
 
 from .constants import (
     CONF_CLIENT_ID,
+    CONF_PLAYED_THRESHOLD,
     CONF_REFRESH_TOKEN,
+    CONF_SYNC_PLAYED_STATUS,
     LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX,
+    SUPPORTED_FEATURES,
 )
 from .helpers import get_librespot_binary
-from .parsers import parse_album, parse_artist, parse_playlist, parse_track
+from .parsers import (
+    parse_album,
+    parse_artist,
+    parse_playlist,
+    parse_podcast,
+    parse_podcast_episode,
+    parse_track,
+)
 from .streaming import LibrespotStreamer
 
 if TYPE_CHECKING:
-    from collections.abc import AsyncGenerator
+    from music_assistant_models.config_entries import ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant import MusicAssistant
 
 
 class SpotifyProvider(MusicProvider):
     """Implementation of a Spotify MusicProvider."""
 
-    _auth_info: str | None = None
+    _auth_info: dict[str, Any] | None = None
     _sp_user: dict[str, Any] | None = None
     _librespot_bin: str | None = None
     custom_client_id_active: bool = False
     throttler: ThrottlerManager
 
+    def __init__(
+        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+    ) -> None:
+        """Initialize the provider."""
+        super().__init__(mass, manifest, config)
+
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
@@ -76,25 +99,31 @@ class SpotifyProvider(MusicProvider):
         # try login which will raise if it fails
         await self.login()
 
+    @property
+    def sync_played_status_enabled(self) -> bool:
+        """Check if played status sync is enabled."""
+        value = self.config.get_value(CONF_SYNC_PLAYED_STATUS, True)
+        return bool(value) if value is not None else True
+
+    @property
+    def played_threshold(self) -> float:
+        """Get the played threshold percentage."""
+        value = self.config.get_value(CONF_PLAYED_THRESHOLD, 90)
+        if isinstance(value, (int, float)):
+            # Convert from 1-100 percentage to 0.0-1.0 decimal
+            return float(value) / 100.0
+        elif isinstance(value, str):
+            try:
+                return float(value) / 100.0
+            except ValueError:
+                return 0.9  # fallback to default (90%)
+        else:
+            return 0.9  # fallback to default for any other type
+
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        base = {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.LIBRARY_ARTISTS_EDIT,
-            ProviderFeature.LIBRARY_ALBUMS_EDIT,
-            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
-            ProviderFeature.LIBRARY_TRACKS_EDIT,
-            ProviderFeature.PLAYLIST_TRACKS_EDIT,
-            ProviderFeature.PLAYLIST_CREATE,
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.ARTIST_ALBUMS,
-            ProviderFeature.ARTIST_TOPTRACKS,
-        }
+        base = SUPPORTED_FEATURES.copy()
         if not self.custom_client_id_active:
             # Spotify has killed the similar tracks api for developers
             # https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
@@ -119,6 +148,8 @@ class SpotifyProvider(MusicProvider):
         """
         searchresult = SearchResults()
         searchtypes = []
+        if media_types is None:
+            return searchresult
         if MediaType.ARTIST in media_types:
             searchtypes.append("artist")
         if MediaType.ALBUM in media_types:
@@ -127,6 +158,8 @@ class SpotifyProvider(MusicProvider):
             searchtypes.append("track")
         if MediaType.PLAYLIST in media_types:
             searchtypes.append("playlist")
+        if MediaType.PODCAST in media_types:
+            searchtypes.append("show")
         if not searchtypes:
             return searchresult
         searchtype = ",".join(searchtypes)
@@ -139,33 +172,45 @@ class SpotifyProvider(MusicProvider):
                 "search", q=search_query, type=searchtype, limit=page_limit, offset=offset
             )
             if "artists" in api_result:
-                searchresult.artists += [
+                artists = [
                     parse_artist(item, self)
                     for item in api_result["artists"]["items"]
                     if (item and item["id"] and item["name"])
                 ]
+                searchresult.artists = [*searchresult.artists, *artists]
                 items_received += len(api_result["artists"]["items"])
             if "albums" in api_result:
-                searchresult.albums += [
+                albums = [
                     parse_album(item, self)
                     for item in api_result["albums"]["items"]
                     if (item and item["id"])
                 ]
+                searchresult.albums = [*searchresult.albums, *albums]
                 items_received += len(api_result["albums"]["items"])
             if "tracks" in api_result:
-                searchresult.tracks += [
+                tracks = [
                     parse_track(item, self)
                     for item in api_result["tracks"]["items"]
                     if (item and item["id"])
                 ]
+                searchresult.tracks = [*searchresult.tracks, *tracks]
                 items_received += len(api_result["tracks"]["items"])
             if "playlists" in api_result:
-                searchresult.playlists += [
+                playlists = [
                     parse_playlist(item, self)
                     for item in api_result["playlists"]["items"]
                     if (item and item["id"])
                 ]
+                searchresult.playlists = [*searchresult.playlists, *playlists]
                 items_received += len(api_result["playlists"]["items"])
+            if "shows" in api_result:
+                podcasts = [
+                    parse_podcast(item, self)
+                    for item in api_result["shows"]["items"]
+                    if (item and item["id"])
+                ]
+                searchresult.podcasts = [*searchresult.podcasts, *podcasts]
+                items_received += len(api_result["shows"]["items"])
             offset += page_limit
             if offset >= limit:
                 break
@@ -203,10 +248,19 @@ class SpotifyProvider(MusicProvider):
             if item and item["track"]["id"]:
                 yield parse_track(item["track"], self)
 
+    async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
+        """Retrieve library podcasts from spotify."""
+        async for item in self._get_all_items("me/shows"):
+            if item["show"] and item["show"]["id"]:
+                yield parse_podcast(item["show"], self)
+
     def _get_liked_songs_playlist_id(self) -> str:
         return f"{LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX}-{self.instance_id}"
 
     async def _get_liked_songs_playlist(self) -> Playlist:
+        if self._sp_user is None:
+            raise LoginFailed("User info not available - not logged in")
+
         liked_songs = Playlist(
             item_id=self._get_liked_songs_playlist_id(),
             provider=self.lookup_key,
@@ -224,14 +278,17 @@ class SpotifyProvider(MusicProvider):
 
         liked_songs.is_editable = False  # TODO Editing requires special endpoints
 
-        liked_songs.metadata.images = [
-            MediaItemImage(
-                type=ImageType.THUMB,
-                path="https://misc.scdn.co/liked-songs/liked-songs-64.png",
-                provider=self.lookup_key,
-                remotely_accessible=True,
-            )
-        ]
+        # Add image to the playlist metadata
+        image = MediaItemImage(
+            type=ImageType.THUMB,
+            path="https://misc.scdn.co/liked-songs/liked-songs-64.png",
+            provider=self.lookup_key,
+            remotely_accessible=True,
+        )
+        if liked_songs.metadata.images is None:
+            liked_songs.metadata.images = UniqueList([image])
+        else:
+            liked_songs.metadata.images.append(image)
 
         liked_songs.cache_checksum = str(time.time())
 
@@ -267,6 +324,104 @@ class SpotifyProvider(MusicProvider):
         playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
         return parse_playlist(playlist_obj, self)
 
+    async def get_podcast(self, prov_podcast_id: str) -> Podcast:
+        """Get full podcast details by id."""
+        podcast_obj = await self._get_data(f"shows/{prov_podcast_id}")
+        if not podcast_obj:
+            raise MediaNotFoundError(f"Podcast not found: {prov_podcast_id}")
+        return parse_podcast(podcast_obj, self)
+
+    async def get_podcast_episodes(
+        self, prov_podcast_id: str
+    ) -> AsyncGenerator[PodcastEpisode, None]:
+        """Get all podcast episodes."""
+        podcast = await self.get_podcast(prov_podcast_id)
+        episode_position = 1
+
+        async for item in self._get_all_items(
+            f"shows/{prov_podcast_id}/episodes", market="from_token"
+        ):
+            if not (item and item["id"]):
+                continue
+
+            episode = parse_podcast_episode(item, self, podcast=podcast)
+            episode.position = episode_position
+            episode_position += 1
+            yield episode
+
+    async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
+        """Get full podcast episode details by id."""
+        episode_obj = await self._get_data(f"episodes/{prov_episode_id}", market="from_token")
+        if not episode_obj:
+            raise MediaNotFoundError(f"Episode not found: {prov_episode_id}")
+        return parse_podcast_episode(episode_obj, self)
+
+    async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
+        """Get resume position for episode from Spotify."""
+        if media_type != MediaType.PODCAST_EPISODE:
+            raise NotImplementedError("Resume position only supported for podcast episodes")
+
+        if not self.sync_played_status_enabled:
+            raise NotImplementedError("Spotify resume sync disabled in settings")
+
+        episode_obj = await self._get_data(f"episodes/{item_id}", market="from_token")
+
+        if not episode_obj:
+            raise NotImplementedError("No episode data from Spotify")
+
+        if "resume_point" not in episode_obj or not episode_obj["resume_point"]:
+            raise NotImplementedError("No resume point data from Spotify")
+
+        try:
+            resume_point = episode_obj["resume_point"]
+            fully_played = resume_point.get("fully_played", False)
+            position_ms = resume_point.get("resume_position_ms", 0)
+
+            # Apply played threshold logic
+            if not fully_played and episode_obj.get("duration_ms", 0) > 0:
+                completion_ratio = position_ms / episode_obj["duration_ms"]
+                if completion_ratio >= self.played_threshold:
+                    fully_played = True
+
+            return fully_played, position_ms
+        except (KeyError, TypeError, AttributeError) as e:
+            self.logger.debug(f"Invalid resume point data structure for {item_id}: {e}")
+            raise NotImplementedError("Invalid resume point data from Spotify")
+
+    async def on_played(
+        self,
+        media_type: MediaType,
+        prov_item_id: str,
+        fully_played: bool,
+        position: int,
+        media_item: MediaItemType,
+        is_playing: bool = False,
+    ) -> None:
+        """
+        Call when an episode is played in MA.
+
+        Note: This CANNOT sync back to Spotify as there's no API for it.
+        This is just for logging/monitoring purposes.
+        """
+        if media_type != MediaType.PODCAST_EPISODE:
+            return
+
+        if not isinstance(media_item, PodcastEpisode):
+            return
+
+        # Handle case where position might be None (e.g., when marked as played in UI)
+        safe_position = position or 0
+        if media_item.duration > 0:
+            completion_percentage = (safe_position / media_item.duration) * 100
+        else:
+            completion_percentage = 0
+
+        self.logger.debug(
+            f"Episode played in MA: {prov_item_id} "
+            f"({completion_percentage:.1f}%, fully_played: {fully_played}) "
+            f"- Cannot sync back to Spotify due to API limitations"
+        )
+
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Get all album tracks for given album id."""
         return [
@@ -326,6 +481,8 @@ class SpotifyProvider(MusicProvider):
             await self._put_data("me/tracks", {"ids": [item.item_id]})
         elif item.media_type == MediaType.PLAYLIST:
             await self._put_data(f"playlists/{item.item_id}/followers", data={"public": False})
+        elif item.media_type == MediaType.PODCAST:
+            await self._put_data("me/shows", ids=item.item_id)
         return True
 
     async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
@@ -338,6 +495,8 @@ class SpotifyProvider(MusicProvider):
             await self._delete_data("me/tracks", {"ids": [prov_item_id]})
         elif media_type == MediaType.PLAYLIST:
             await self._delete_data(f"playlists/{prov_item_id}/followers")
+        elif media_type == MediaType.PODCAST:
+            await self._delete_data("me/shows", ids=prov_item_id)
         return True
 
     async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
@@ -363,6 +522,8 @@ class SpotifyProvider(MusicProvider):
 
     async def create_playlist(self, name: str) -> Playlist:
         """Create a new playlist on provider with given name."""
+        if self._sp_user is None:
+            raise LoginFailed("User info not available - not logged in")
         data = {"name": name, "public": False}
         new_playlist = await self._post_data(f"users/{self._sp_user['id']}/playlists", data=data)
         self._fix_create_playlist_api_bug(new_playlist)
@@ -375,12 +536,14 @@ class SpotifyProvider(MusicProvider):
         return [parse_track(item, self) for item in items["tracks"] if (item and item["id"])]
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
+        """Return the content details for the given track/episode when it will be streamed."""
         return StreamDetails(
             item_id=item_id,
             provider=self.lookup_key,
+            media_type=media_type,
             audio_format=AudioFormat(
                 content_type=ContentType.OGG,
+                bit_rate=320,
             ),
             stream_type=StreamType.CUSTOM,
             allow_seek=True,
@@ -395,7 +558,7 @@ class SpotifyProvider(MusicProvider):
             yield chunk
 
     @lock
-    async def login(self, force_refresh: bool = False) -> dict:
+    async def login(self, force_refresh: bool = False) -> dict[str, Any]:
         """Log-in Spotify and return Auth/token info."""
         # return existing token if we have one in memory
         if (
@@ -432,19 +595,25 @@ class SpotifyProvider(MusicProvider):
                     await asyncio.sleep(2)
                     continue
                 # if we reached this point, the token has been successfully refreshed
-                auth_info = await response.json()
+                auth_info: dict[str, Any] = await response.json()
                 auth_info["expires_at"] = int(auth_info["expires_in"] + time.time())
                 self.logger.debug("Successfully refreshed access token")
                 break
         else:
             if self.available:
-                self.mass.create_task(self.mass.unload_provider_with_error(self.instance_id))
+                self.mass.create_task(
+                    self.mass.unload_provider_with_error(
+                        self.instance_id, f"Failed to refresh access token: {err}"
+                    )
+                )
             raise LoginFailed(f"Failed to refresh access token: {err}")
 
         # make sure that our updated creds get stored in memory + config
         self._auth_info = auth_info
         self.update_config_value(CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True)
         # check if librespot still has valid auth
+        if self._librespot_bin is None:
+            raise LoginFailed("Librespot binary not available")
         args = [
             self._librespot_bin,
             "--cache",
@@ -505,7 +674,10 @@ class SpotifyProvider(MusicProvider):
         headers["Accept-Language"] = f"{locale}, {language};q=0.9, *;q=0.5"
         async with (
             self.mass.http_session.get(
-                url, headers=headers, params=kwargs, ssl=True, timeout=120
+                url,
+                headers=headers,
+                params=kwargs,
+                timeout=aiohttp.ClientTimeout(total=120),
             ) as response,
         ):
             # handle spotify rate limiter
@@ -522,13 +694,14 @@ class SpotifyProvider(MusicProvider):
             # so it will be retried (and the token refreshed)
             if response.status == 401:
                 self._auth_info = None
-                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=0.05)
+                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
 
             # handle 404 not found, convert to MediaNotFoundError
             if response.status == 404:
                 raise MediaNotFoundError(f"{endpoint} not found")
             response.raise_for_status()
-            return await response.json(loads=json_loads)
+            result: dict[str, Any] = await response.json(loads=json_loads)
+            return result
 
     @throttle_with_retries
     async def _delete_data(self, endpoint: str, data: Any = None, **kwargs: Any) -> None:
@@ -537,7 +710,7 @@ class SpotifyProvider(MusicProvider):
         auth_info = kwargs.pop("auth_info", await self.login())
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.delete(
-            url, headers=headers, params=kwargs, json=data, ssl=False
+            url, headers=headers, params=kwargs, json=data, ssl=True
         ) as response:
             # handle spotify rate limiter
             if response.status == 429:
@@ -549,7 +722,7 @@ class SpotifyProvider(MusicProvider):
             # so it will be retried (and the token refreshed)
             if response.status == 401:
                 self._auth_info = None
-                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=0.05)
+                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
             # handle temporary server error
             if response.status in (502, 503):
                 raise ResourceTemporarilyUnavailable(backoff_time=30)
@@ -562,7 +735,7 @@ class SpotifyProvider(MusicProvider):
         auth_info = kwargs.pop("auth_info", await self.login())
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.put(
-            url, headers=headers, params=kwargs, json=data, ssl=False
+            url, headers=headers, params=kwargs, json=data, ssl=True
         ) as response:
             # handle spotify rate limiter
             if response.status == 429:
@@ -574,7 +747,7 @@ class SpotifyProvider(MusicProvider):
             # so it will be retried (and the token refreshed)
             if response.status == 401:
                 self._auth_info = None
-                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=0.05)
+                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
 
             # handle temporary server error
             if response.status in (502, 503):
@@ -588,7 +761,7 @@ class SpotifyProvider(MusicProvider):
         auth_info = kwargs.pop("auth_info", await self.login())
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.post(
-            url, headers=headers, params=kwargs, json=data, ssl=False
+            url, headers=headers, params=kwargs, json=data, ssl=True
         ) as response:
             # handle spotify rate limiter
             if response.status == 429:
@@ -600,15 +773,19 @@ class SpotifyProvider(MusicProvider):
             # so it will be retried (and the token refreshed)
             if response.status == 401:
                 self._auth_info = None
-                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=0.05)
+                raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
             # handle temporary server error
             if response.status in (502, 503):
                 raise ResourceTemporarilyUnavailable(backoff_time=30)
             response.raise_for_status()
-            return await response.json(loads=json_loads)
+            result: dict[str, Any] = await response.json(loads=json_loads)
+            return result
 
     def _fix_create_playlist_api_bug(self, playlist_obj: dict[str, Any]) -> None:
         """Fix spotify API bug where incorrect owner id is returned from Create Playlist."""
+        if self._sp_user is None:
+            raise LoginFailed("User info not available - not logged in")
+
         if playlist_obj["owner"]["id"] != self._sp_user["id"]:
             playlist_obj["owner"]["id"] = self._sp_user["id"]
             playlist_obj["owner"]["display_name"] = self._sp_user["display_name"]
index 379e1ec39eaf660becb04801c4a2643e91d3e028..89c70f665218e2797f150ebbd17c5020fff2a017 100644 (file)
@@ -6,6 +6,7 @@ import asyncio
 from collections.abc import AsyncGenerator
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import MediaType
 from music_assistant_models.errors import AudioError
 
 from music_assistant.constants import VERBOSE_LOG_LEVEL
@@ -28,10 +29,15 @@ class LibrespotStreamer:
         self, streamdetails: StreamDetails, seek_position: int = 0
     ) -> AsyncGenerator[bytes, None]:
         """Return the audio stream for the provider item."""
-        spotify_uri = f"spotify://track:{streamdetails.item_id}"
+        # Ensure librespot binary is available
+        assert self.provider._librespot_bin
+
+        media_type = "episode" if streamdetails.media_type == MediaType.PODCAST_EPISODE else "track"
+        spotify_uri = f"spotify://{media_type}:{streamdetails.item_id}"
         self.provider.logger.log(
             VERBOSE_LOG_LEVEL, f"Start streaming {spotify_uri} using librespot"
         )
+
         args = [
             self.provider._librespot_bin,
             "--cache",
@@ -65,10 +71,10 @@ class LibrespotStreamer:
                 try:
                     chunk = await asyncio.wait_for(librespot_proc.read(64000), timeout=10 * attempt)
                     if not chunk:
-                        raise AudioError
+                        raise AudioError(f"No audio data received from librespot for {spotify_uri}")
                     yield chunk
                 except (TimeoutError, AudioError):
-                    err_mesg = "No audio received from librespot within timeout"
+                    err_mesg = f"No audio received from librespot within timeout for {spotify_uri}"
                     if attempt == 2:
                         raise AudioError(err_mesg)
                     self.provider.logger.warning("%s - will retry once", err_mesg)