From 09a692280bd524c4ef48bfe86f9831c8aee3f9ce Mon Sep 17 00:00:00 2001 From: OzGav Date: Mon, 9 Feb 2026 17:35:38 +1000 Subject: [PATCH] Allow radio stations to be added to playlists (#2951) * Add support for radio station URIs in playlists Enable radio stations to be added to Music Assistant (builtin) playlists by expanding the playlist track type system to support both Track and Radio items. Changes: - playlists.py: Add RADIO media type handling in add_playlist_tracks() - playlists.py: Update return types to support Track | Radio - music_provider.py: Expand get_playlist_tracks() return type to list[Track | Radio] - builtin/__init__.py: Replace Track assertion with type check for Track | Radio This allows users to mix tracks and radio stations in the same playlist, with radio URIs stored and retrieved alongside track URIs. * Add validation to restrict radio items to builtin playlists only Radio items can only be added to builtin playlists because other streaming providers (Spotify, Apple Music, etc.) don't support arbitrary radio stream URLs in their playlists. This adds backend validation to enforce this constraint and provide a clear error message when attempted. * Fix bug: Radio URIs were being resolved as track IDs When adding radio items with library URIs (library://radio/ID) to builtin playlists, the code was incorrectly passing through track-specific resolution logic that called tracks.get(ID) instead of radio.get(ID), causing the wrong items to be added. Fixed by handling all radio items for builtin playlists early in the URI processing, adding them directly by URI before the track-specific logic runs. This ensures library://radio/8 adds radio ID 8, not track ID 8. * Fix metadata and queue controllers to handle Radio items in playlists The metadata controller was crashing when processing playlists containing radio items because it tried to access track.album on Radio objects. The player_queues controller had incorrect type annotations that assumed playlist items were always Tracks. Changes: - metadata.py: Add isinstance(track, Track) check before accessing .album - player_queues.py: Update get_playlist_tracks() return type to Track | Radio - player_queues.py: Add Radio to imports Both controllers now properly handle playlists containing mixed Track and Radio items. * Fix mypy type errors for radio playlist support The previous implementation changed the base MusicProvider.get_playlist_tracks() interface to return list[Track | Radio], which caused type errors across all provider implementations that only return list[Track]. Fixes: 1. Reverted base interface to return list[Track] (standard for all providers) 2. Builtin provider overrides with list[Track | Radio] (covariant return type) 3. Added type: ignore[override] for builtin's covariant override 4. Added type: ignore[return-value] where playlist controller calls providers 5. Fixed radio_mode_base_tracks to filter out Radio items (only works with Track) 6. Fixed variable naming: 'track' -> 'item' where both types are possible This approach is correct because: - Only builtin provider supports radio in playlists - All other providers (Spotify, Tidal, etc.) only support tracks - Python's type system supports covariant return types in overrides - list[Track] is compatible with list[Track | Radio] at call sites * Refactor playlist URI handling logic for clarity The previous logic combined radio and track handling in one condition, making it unclear why library URIs were being added for radio items. Separated into two clear cases: 1. Radio items: ALWAYS add by URI (even library://radio/ID) - Radio items cannot go through track-matching logic - They must be retrieved by their full URI - This is a necessary exception to the "no library URIs" rule 2. Track items: Only add non-library URIs directly - Provider URIs (spotify://track/xyz) are portable - Library URIs (library://track/123) fall through to matching logic - This finds the actual provider URI to store instead This addresses the review concern that the logic didn't make sense and makes the special handling of radio items explicit. * Convert library radio URIs to provider URIs for DB rebuild resilience Previously, library radio URIs (library://radio/8) were being stored directly in playlists. This would break on database rebuilds when the radio item gets a different database ID. Now when adding a library radio URI: 1. Fetch the full radio item from the library 2. Get its provider mapping (typically builtin) 3. Extract the actual provider item_id (the real stream URL) 4. Create and store the provider URI (builtin://radio/https://stream...) This ensures playlists contain portable provider URIs with the actual stream URLs, surviving database rebuilds. Example: - Before: Stored library://radio/8 (breaks on rebuild) - After: Stored builtin://radio/https://stream.example.com/radio.mp3 (portable) Provider radio URIs are already portable and stored as-is. * Apply reviewer suggestion: simplify comment in radio_mode_base_tracks Changed comment from 'filter out unavailable tracks and radio items' to 'filter out unavailable items' as suggested by reviewer for better clarity. * Make playlist validation generic for non-track items Changed validation from checking specifically for radio items to checking for any non-track media type. This makes the code more future-proof for supporting other media types like podcast episodes in playlists. Applied reviewer suggestion to make the logic more generic. * Make library URI conversion universal using get_item_by_uri Refactored the radio-specific library URI conversion to work universally for all non-track media types (radio, podcast episodes, audiobooks, etc.) by using self.mass.music.get_item_by_uri() instead of calling media-type- specific controllers. This addresses reviewer feedback to avoid code duplication and makes the backend ready to support additional media types in playlists as the frontend adds support for them. Changes: - Changed condition from "media_type == MediaType.RADIO" to "media_type != MediaType.TRACK" - Use get_item_by_uri() to fetch items generically - Renamed variables from radio-specific to generic (prov_mapping, full_item) - Updated comments to reflect universal support * Add type safety check for items with provider_mappings Fixed mypy error where get_item_by_uri can return BrowseFolder which doesn't have provider_mappings attribute. Added hasattr check and restructured to use if/else instead of early continue to properly handle items without provider mappings. * Replace hasattr with isinstance for type-safe media item checking Replaced hasattr() check with isinstance() for better type safety: - Added Audiobook and PodcastEpisode to imports - Use isinstance(full_item, (Radio, Audiobook, PodcastEpisode)) - Removed need for type: ignore[attr-defined] comment - mypy now understands full_item has provider_mappings attribute This is more explicit, type-safe, and self-documenting about which media types are supported in playlists. * Add PlaylistItem type alias for better maintainability Created a shared type alias PlaylistItem = Track | Radio | PodcastEpisode | Audiobook to avoid hardcoding union types in multiple places. Changes: - playlists.py: Added PlaylistItem type alias at top of file - playlists.py: Updated all Track | Radio annotations to use PlaylistItem - player_queues.py: Added PlaylistItem type alias - player_queues.py: Updated get_playlist_tracks to use PlaylistItem - builtin/__init__.py: Added Audiobook and PodcastEpisode to imports - builtin/__init__.py: Updated all type annotations to include all supported types - builtin/__init__.py: Updated isinstance checks to include all types Now there's a single source of truth for what media types can be in playlists. Adding future types only requires changing the type alias definition. * Add PlaylistItem type alias for better maintainability Added type aliases and constants to keep all playlist item type definitions synchronized: - PlaylistItem type alias defined in playlists.py, builtin/__init__.py, and player_queues.py - PLAYLIST_MEDIA_TYPES constant for MediaType enum checks - PLAYLIST_ITEM_CLASSES constant for isinstance checks This ensures that adding new playlist item types in the future only requires updating the type alias and constants, with all runtime checks automatically including the new type. * Centralize playlist item type definitions in constants.py Moved all playlist item type definitions to a single location in constants.py to eliminate duplication and ensure consistency: - PlaylistItem type alias - PLAYLIST_MEDIA_TYPES tuple for MediaType enum checks - PLAYLIST_ITEM_CLASSES tuple for isinstance checks - PLAYLIST_NON_TRACK_ITEM_CLASSES for cases where Track needs separate handling Updated playlists.py, builtin/__init__.py, and player_queues.py to import from constants.py. Also improved comments: - Better explanation of type ignore for covariant override - More generic comment for radio mode filtering - Updated to reference PlaylistItem instead of specific types * Replace hardcoded types with type ignore comments Removed cast statements with hardcoded types and replaced with type ignore comments for mypy: - playlists.py: Added type: ignore[union-attr] for provider_mappings access - builtin/__init__.py: Added type: ignore[attr-defined] and type: ignore[arg-type] for position and append This ensures all type checking uses the constants from constants.py without any hardcoded type repetition. * Use cast with type alias constants instead of type ignore Added PlaylistNonTrackItem type alias to constants.py and updated code to use cast() with type aliases instead of type ignore comments: - playlists.py: cast(PlaylistNonTrackItem, full_item) - builtin/__init__.py: cast(PlaylistItem, track) This maintains type safety while using only the centralized type definitions from constants.py. * Simplify type definitions using get_args() Reduced complexity by deriving PLAYLIST_ITEM_CLASSES from PlaylistItem using get_args(): - Removed PlaylistNonTrackItem type alias (only used once) - PLAYLIST_ITEM_CLASSES now auto-derived from PlaylistItem - Replaced cast with type ignore comment for single non-track case This reduces maintenance: adding a new type only requires updating PlaylistItem and PLAYLIST_MEDIA_TYPES. * Use get_args() inline instead of PLAYLIST_ITEM_CLASSES constant Removed PLAYLIST_ITEM_CLASSES constant and use get_args(PlaylistItem) directly at the single call site. This reduces to just 3 definitions in constants.py: - PlaylistItem type alias - PLAYLIST_MEDIA_TYPES tuple - PLAYLIST_NON_TRACK_ITEM_CLASSES tuple * Add type ignore for cast assignment in builtin provider Added type: ignore[assignment] comment to fix mypy error where cast result is assigned to broader-typed variable. * Use new variable name instead of type ignore for cast Instead of reassigning track with a type ignore, use a new variable playlist_item. This makes the isinstance check meaningful for type narrowing without needing type ignore comments. * Clarify comments about playlist types in builtin provider Updated comments to distinguish between: - System-generated playlists (favorites, random, etc.) which only contain tracks - User-created playlists which can contain Track, Radio, PodcastEpisode, and Audiobook items This makes it clear that the new support for non-track items only applies to user-created playlists. * Lint * Unify track and non-track playlist item handling logic Merged separate code paths for tracks and non-track items into unified logic: - Same structure for all media types (Track, Radio, PodcastEpisode, Audiobook) - For builtin playlists: Get full item, iterate provider mappings, add first available - Tracks get quality sorting before selection - Non-track items use first available mapping (typically only one) This simplifies maintenance and follows the same pattern for all playlist item types. * Fix mypy error in unified playlist logic Added cast to Track type for match_provider call since full_item has broad union type from get_item_by_uri(). We know it's a Track from the media_type check but mypy can't infer this. * Rename PlaylistItem to PlaylistPlayableItem to avoid naming conflict The PlaylistItem union type conflicted with the existing PlaylistItem dataclass in music_assistant.helpers.playlists, causing import collisions and confusing type hints. Renamed the union type to PlaylistPlayableItem to clearly distinguish it from the helper dataclass. Changes: - Renamed type alias in constants.py - Updated all imports and usages in: - playlists.py (import, return types, comments) - builtin/__init__.py (import, return type, isinstance, cast) - player_queues.py (import, return types) - Updated comments to reference new name * Remove unused constant and duplicate import - Remove PLAYLIST_NON_TRACK_ITEM_CLASSES as it was declared but never used - Remove duplicate AsyncGenerator import from TYPE_CHECKING block in playlists.py * Sort provider mappings for deterministic non-track item selection --------- Co-authored-by: Claude --- music_assistant/constants.py | 21 ++- .../controllers/media/playlists.py | 142 ++++++++++++++---- music_assistant/controllers/metadata.py | 7 +- music_assistant/controllers/player_queues.py | 8 +- music_assistant/providers/builtin/__init__.py | 37 +++-- 5 files changed, 165 insertions(+), 50 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index c54c27b6..dd5d7786 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -9,11 +9,28 @@ from music_assistant_models.config_entries import ( ConfigEntry, ConfigValueOption, ) -from music_assistant_models.enums import ConfigEntryType, ContentType -from music_assistant_models.media_items import AudioFormat +from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType +from music_assistant_models.media_items import ( + Audiobook, + AudioFormat, + PodcastEpisode, + Radio, + Track, +) APPLICATION_NAME: Final = "Music Assistant" +# Type alias for items that can be added to playlists +PlaylistPlayableItem = Track | Radio | PodcastEpisode | Audiobook + +# Corresponding MediaType enum values (must match PlaylistPlayableItem types above) +PLAYLIST_MEDIA_TYPES: Final[tuple[MediaType, ...]] = ( + MediaType.TRACK, + MediaType.RADIO, + MediaType.PODCAST_EPISODE, + MediaType.AUDIOBOOK, +) + API_SCHEMA_VERSION: Final[int] = 28 MIN_SCHEMA_VERSION: Final[int] = 28 diff --git a/music_assistant/controllers/media/playlists.py b/music_assistant/controllers/media/playlists.py index ce34c0b1..41a5517f 100644 --- a/music_assistant/controllers/media/playlists.py +++ b/music_assistant/controllers/media/playlists.py @@ -12,9 +12,16 @@ from music_assistant_models.errors import ( MediaNotFoundError, ProviderUnavailableError, ) -from music_assistant_models.media_items import Playlist, Track +from music_assistant_models.media_items import ( + Playlist, + Track, +) -from music_assistant.constants import DB_TABLE_PLAYLISTS +from music_assistant.constants import ( + DB_TABLE_PLAYLISTS, + PLAYLIST_MEDIA_TYPES, + PlaylistPlayableItem, +) from music_assistant.helpers.compare import create_safe_string from music_assistant.helpers.database import UNSET from music_assistant.helpers.json import serialize_to_json @@ -82,7 +89,7 @@ class PlaylistController(MediaControllerBase[Playlist]): item_id: str, provider_instance_id_or_domain: str, force_refresh: bool = False, - ) -> AsyncGenerator[Track, None]: + ) -> AsyncGenerator[PlaylistPlayableItem, None]: """Return playlist tracks for the given provider playlist id.""" if provider_instance_id_or_domain == "library": library_item = await self.get_library_item(item_id) @@ -188,14 +195,16 @@ class PlaylistController(MediaControllerBase[Playlist]): if track.uri is not None: unwrapped_uris.append(track.uri) elif media_type == MediaType.PLAYLIST: - async for track in self.tracks(item_id, provider_instance_id_or_domain): - if track.uri is not None: - unwrapped_uris.append(track.uri) - elif media_type == MediaType.TRACK: + async for item in self.tracks(item_id, provider_instance_id_or_domain): + if item.uri is not None: + unwrapped_uris.append(item.uri) + elif media_type in PLAYLIST_MEDIA_TYPES: unwrapped_uris.append(uri) else: self.logger.warning( - "Not adding %s to playlist %s - not a track", uri, playlist.name + "Not adding %s to playlist %s - media type not supported in playlists", + uri, + playlist.name, ) continue @@ -215,6 +224,15 @@ class PlaylistController(MediaControllerBase[Playlist]): # parse uri for further processing media_type, provider_instance_id_or_domain, item_id = await parse_uri(uri) + # non-track items can only be added to builtin playlists + if media_type != MediaType.TRACK and playlist_prov.domain != "builtin": + self.logger.warning( + "Not adding %s to playlist %s - only supported in builtin playlists", + uri, + playlist.name, + ) + continue + # skip if item already in the playlist if item_id in cur_playlist_track_ids: self.logger.warning( @@ -225,16 +243,79 @@ class PlaylistController(MediaControllerBase[Playlist]): continue # special: the builtin provider can handle uri's from all providers (with uri as id) - if provider_instance_id_or_domain != "library" and playlist_prov.domain == "builtin": - # note: we try not to add library uri's to the builtin playlists - # so we can survive db rebuilds - if uri not in ids_to_add: - ids_to_add.append(uri) - self.logger.info( - "Adding %s to playlist %s", - uri, - playlist.name, - ) + if playlist_prov.domain == "builtin": + # For non-library URIs, add directly (they're already portable provider URIs) + if provider_instance_id_or_domain != "library": + if uri not in ids_to_add: + ids_to_add.append(uri) + self.logger.info( + "Adding %s to playlist %s", + uri, + playlist.name, + ) + continue + # For library URIs, convert to provider URIs to survive DB rebuilds + # Get the full item from library to access all provider mappings + full_item = await self.mass.music.get_item_by_uri(uri) + if not hasattr(full_item, "provider_mappings"): + self.logger.warning( + "Can't add %s to playlist %s - unsupported media type", + uri, + playlist.name, + ) + continue + + # For tracks, try to match to playlist provider + # For non-track items, just use first available mapping + provider_mappings = full_item.provider_mappings + if media_type == MediaType.TRACK: + # Cast to Track for mypy - we know it's a track from media_type check + full_track = cast("Track", full_item) + # Try to match the track to additional providers + track_prov_domains = {x.provider_domain for x in provider_mappings} + if ( + playlist_prov.is_streaming_provider + and playlist_prov.domain not in track_prov_domains + ): + provider_mappings.update( + await self.mass.music.tracks.match_provider( + full_track, playlist_prov, strict=False + ) + ) + + # Sort by quality (highest first) for deterministic selection + provider_mappings = sorted(provider_mappings, key=lambda x: x.quality, reverse=True) + + # Add first available provider mapping + for prov_mapping in provider_mappings: + if not prov_mapping.available: + continue + item_prov = self.mass.get_provider(prov_mapping.provider_instance) + if not item_prov: + continue + # Create provider URI from the mapping + provider_uri = create_uri( + media_type, + item_prov.instance_id, + prov_mapping.item_id, + ) + if ( + provider_uri not in ids_to_add + and provider_uri not in cur_playlist_track_uris + ): + ids_to_add.append(provider_uri) + self.logger.info( + "Adding %s to playlist %s", + provider_uri, + playlist.name, + ) + break + else: + self.logger.warning( + "Can't add %s to playlist %s - no available provider mapping", + uri, + playlist.name, + ) continue # if target playlist is an exact provider match, we can add it @@ -252,7 +333,8 @@ class PlaylistController(MediaControllerBase[Playlist]): ids_to_add.append(item_id) continue - # ensure we have a full (library) track (including all provider mappings) + # For provider-specific playlists: match tracks with quality sorting + # (Non-track items can only be added to builtin playlists, validated earlier) full_track = await self.mass.music.tracks.get( item_id, provider_instance_id_or_domain, @@ -295,16 +377,7 @@ class PlaylistController(MediaControllerBase[Playlist]): playlist.name, ) break # already existing in the playlist - if playlist_prov.domain == "builtin": - # the builtin provider can handle uri's from all providers (with uri as id) - if track_version_uri not in ids_to_add: - ids_to_add.append(track_version_uri) - self.logger.info( - "Adding %s to playlist %s", - full_track.name, - playlist.name, - ) - break + # Add track to provider-specific playlist if item_prov.instance_id == playlist_prov.instance_id: if track_version.item_id not in ids_to_add: ids_to_add.append(track_version.item_id) @@ -428,14 +501,17 @@ class PlaylistController(MediaControllerBase[Playlist]): provider_instance_id_or_domain: str, page: int = 0, force_refresh: bool = False, - ) -> list[Track]: + ) -> list[PlaylistPlayableItem]: """Return playlist tracks for the given provider playlist id.""" assert provider_instance_id_or_domain != "library" if not (provider := self.mass.get_provider(provider_instance_id_or_domain)): return [] provider = cast("MusicProvider", provider) async with self.mass.cache.handle_refresh(force_refresh): - return await provider.get_playlist_tracks(item_id, page=page) + # Builtin provider overrides to return list[PlaylistPlayableItem], + # others return list[Track]. Since Track is part of PlaylistPlayableItem union, + # this is safe at runtime. Type ignore needed because list is invariant. + return await provider.get_playlist_tracks(item_id, page=page) # type: ignore[return-value] async def radio_mode_base_tracks( self, @@ -451,8 +527,8 @@ class PlaylistController(MediaControllerBase[Playlist]): return [ x async for x in self.tracks(item.item_id, item.provider) - # filter out unavailable tracks - if x.available + # Radio mode only works with Tracks (filter out all other types) + if isinstance(x, Track) and x.available ] async def match_providers(self, db_item: Playlist) -> None: diff --git a/music_assistant/controllers/metadata.py b/music_assistant/controllers/metadata.py index 1c513544..ac6d0065 100644 --- a/music_assistant/controllers/metadata.py +++ b/music_assistant/controllers/metadata.py @@ -753,7 +753,12 @@ class MetaDataController(CoreController): all_playlist_tracks_images.append(track.image) if track.metadata.genres: genres = track.metadata.genres - elif track.album and isinstance(track.album, Album) and track.album.metadata.genres: + elif ( + isinstance(track, Track) + and track.album + and isinstance(track.album, Album) + and track.album.metadata.genres + ): genres = track.album.metadata.genres else: genres = set() diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 4cd386e7..d4063bdf 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -66,6 +66,7 @@ from music_assistant.constants import ( ATTR_ANNOUNCEMENT_IN_PROGRESS, MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL, + PlaylistPlayableItem, ) from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user from music_assistant.helpers.api import api_command @@ -84,7 +85,6 @@ if TYPE_CHECKING: from music_assistant import MusicAssistant from music_assistant.models.player import Player - CONF_DEFAULT_ENQUEUE_SELECT_ARTIST = "default_enqueue_select_artist" CONF_DEFAULT_ENQUEUE_SELECT_ALBUM = "default_enqueue_select_album" @@ -1573,9 +1573,11 @@ class PlayerQueuesController(CoreController): result.append(album_track) return result - async def get_playlist_tracks(self, playlist: Playlist, start_item: str | None) -> list[Track]: + async def get_playlist_tracks( + self, playlist: Playlist, start_item: str | None + ) -> list[PlaylistPlayableItem]: """Return tracks for given playlist, based on user preference.""" - result: list[Track] = [] + result: list[PlaylistPlayableItem] = [] start_item_found = False self.logger.info( "Fetching tracks to play for playlist %s", diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py index 580a95ff..d80e5c3f 100644 --- a/music_assistant/providers/builtin/__init__.py +++ b/music_assistant/providers/builtin/__init__.py @@ -6,7 +6,7 @@ import asyncio import os import time from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Final, cast +from typing import TYPE_CHECKING, Final, cast, get_args import aiofiles import shortuuid @@ -36,7 +36,11 @@ from music_assistant_models.media_items import ( ) from music_assistant_models.streamdetails import StreamDetails -from music_assistant.constants import MASS_LOGO, VARIOUS_ARTISTS_FANART +from music_assistant.constants import ( + MASS_LOGO, + VARIOUS_ARTISTS_FANART, + PlaylistPlayableItem, +) from music_assistant.controllers.cache import use_cache from music_assistant.helpers.tags import AudioTags, async_parse_tags from music_assistant.helpers.uri import parse_uri @@ -77,7 +81,6 @@ if TYPE_CHECKING: CACHE_CATEGORY_MEDIA_INFO: Final[int] = 1 CACHE_CATEGORY_PLAYLISTS: Final[int] = 2 - SUPPORTED_FEATURES = { ProviderFeature.BROWSE, ProviderFeature.LIBRARY_TRACKS, @@ -355,15 +358,22 @@ class BuiltinProvider(MusicProvider): self.mass.config.set(key, stored_items) return True - async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]: - """Get playlist tracks.""" + async def get_playlist_tracks( # type: ignore[override] + self, prov_playlist_id: str, page: int = 0 + ) -> list[PlaylistPlayableItem]: + """Get playlist tracks. + + Builtin provider supports Track, Radio, PodcastEpisode, and Audiobook items in playlists. + Overrides base class to return extended union type instead of list[Track]. + """ if page > 0: # paging not supported, we always return the whole list at once return [] if prov_playlist_id in BUILTIN_PLAYLISTS: - return await self._get_builtin_playlist_tracks(prov_playlist_id) - # user created universal playlist - result: list[Track] = [] + # System-generated playlists (favorites, random, etc.) only contain tracks + return list(await self._get_builtin_playlist_tracks(prov_playlist_id)) + # User-created playlists can contain Track, Radio, PodcastEpisode, and Audiobook items + result: list[PlaylistPlayableItem] = [] playlist_items = await self._read_playlist_file_items(prov_playlist_id) for index, uri in enumerate(playlist_items, 1): try: @@ -379,9 +389,14 @@ class BuiltinProvider(MusicProvider): track = await media_controller.get_provider_item( item_id, provider_instance_id_or_domain ) - assert isinstance(track, Track) - track.position = index - result.append(track) + if isinstance(track, get_args(PlaylistPlayableItem)): + playlist_item = cast("PlaylistPlayableItem", track) + playlist_item.position = index + result.append(playlist_item) + else: + self.logger.warning( + "Unsupported media type in playlist %s: %s", prov_playlist_id, type(track) + ) except (MediaNotFoundError, InvalidDataError, ProviderUnavailableError) as err: self.logger.warning( "Skipping %s in playlist %s: %s", uri, prov_playlist_id, str(err) -- 2.34.1