"@khers"
],
"requirements": [
- "py-opensonic==5.3.1"
+ "py-opensonic==7.0.1"
],
"documentation": "https://music-assistant.io/music-providers/subsonic/",
"multi_instance": true
from __future__ import annotations
import logging
+from datetime import datetime
from typing import TYPE_CHECKING
-from music_assistant_models.enums import (
- ImageType,
- MediaType,
-)
+from music_assistant_models.enums import ImageType, MediaType
from music_assistant_models.media_items import (
Album,
Artist,
ItemMapping,
MediaItemImage,
+ Playlist,
+ Podcast,
+ PodcastEpisode,
ProviderMapping,
)
from music_assistant_models.unique_list import UniqueList
from libopensonic.media import AlbumInfo as SonicAlbumInfo
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
+
UNKNOWN_ARTIST_ID = "fake_artist_unknown"
+# 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 = "$!$"
+
+
def parse_artist(
instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
) -> Artist:
)
artist.metadata.images = UniqueList()
- if sonic_artist.cover_id:
+ if sonic_artist.cover_art:
artist.metadata.images.append(
MediaItemImage(
type=ImageType.THUMB,
- path=sonic_artist.cover_id,
+ path=sonic_artist.cover_art,
provider=instance_id,
remotely_accessible=False,
)
if sonic_info:
if sonic_info.biography:
artist.metadata.description = sonic_info.biography
- if sonic_info.small_url:
+ if sonic_info.small_image_url:
artist.metadata.images.append(
MediaItemImage(
type=ImageType.THUMB,
- path=sonic_info.small_url,
+ path=sonic_info.small_image_url,
provider=instance_id,
remotely_accessible=True,
)
)
album.metadata.images = UniqueList()
- if sonic_album.cover_id:
+ if sonic_album.cover_art:
album.metadata.images.append(
MediaItemImage(
type=ImageType.THUMB,
- path=sonic_album.cover_id,
+ path=sonic_album.cover_art,
provider=instance_id,
remotely_accessible=False,
),
)
if sonic_info:
- if sonic_info.small_url:
+ if sonic_info.small_image_url:
album.metadata.images.append(
MediaItemImage(
type=ImageType.THUMB,
- path=sonic_info.small_url,
+ path=sonic_info.small_image_url,
remotely_accessible=False,
provider=instance_id,
)
album.metadata.description = sonic_info.notes
return album
+
+
+def parse_playlist(instance_id: str, sonic_playlist: SonicPlaylist) -> Playlist:
+ """Parse subsonic Playlist into MA Playlist."""
+ playlist = Playlist(
+ item_id=sonic_playlist.id,
+ provider="opensubsonic",
+ name=sonic_playlist.name,
+ is_editable=True,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_playlist.id,
+ provider_domain="opensubsonic",
+ provider_instance=instance_id,
+ )
+ },
+ )
+
+ if sonic_playlist.cover_art:
+ playlist.metadata.images = UniqueList()
+ playlist.metadata.images.append(
+ MediaItemImage(
+ type=ImageType.THUMB,
+ path=sonic_playlist.cover_art,
+ provider=instance_id,
+ remotely_accessible=False,
+ )
+ )
+
+ return playlist
+
+
+def parse_podcast(instance_id: str, sonic_podcast: SonicPodcast) -> Podcast:
+ """Parse Subsonic PodcastChannel into MA Podcast."""
+ podcast = Podcast(
+ item_id=sonic_podcast.id,
+ provider="opensubsonic",
+ name=sonic_podcast.title,
+ uri=sonic_podcast.url,
+ total_episodes=len(sonic_podcast.episode),
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_podcast.id,
+ provider_domain="opensubsonic",
+ provider_instance=instance_id,
+ )
+ },
+ )
+
+ podcast.metadata.description = sonic_podcast.description
+ podcast.metadata.images = UniqueList()
+
+ if sonic_podcast.cover_art:
+ podcast.metadata.images.append(
+ MediaItemImage(
+ type=ImageType.THUMB,
+ path=sonic_podcast.cover_art,
+ provider=instance_id,
+ remotely_accessible=False,
+ )
+ )
+
+ return podcast
+
+
+def parse_epsiode(
+ instance_id: str, sonic_episode: SonicEpisode, sonic_channel: SonicPodcast
+) -> PodcastEpisode:
+ """Parse an Open Subsonic Podcast Episode into an MA PodcastEpisode."""
+ eid = f"{sonic_episode.channel_id}{EP_CHAN_SEP}{sonic_episode.id}"
+ pos = 1
+ for ep in sonic_channel.episode:
+ if ep.id == sonic_episode.id:
+ break
+ pos += 1
+
+ episode = PodcastEpisode(
+ item_id=eid,
+ provider="opensubsonic",
+ name=sonic_episode.title,
+ position=pos,
+ podcast=parse_podcast(instance_id, sonic_channel),
+ provider_mappings={
+ ProviderMapping(
+ item_id=eid,
+ provider_domain="opensubsonic",
+ provider_instance=instance_id,
+ )
+ },
+ duration=sonic_episode.duration,
+ )
+
+ if sonic_episode.publish_date:
+ episode.metadata.release_date = datetime.fromisoformat(sonic_episode.publish_date)
+
+ if sonic_episode.description:
+ episode.metadata.description = sonic_episode.description
+
+ return episode
from __future__ import annotations
import asyncio
-from datetime import datetime
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from libopensonic.connection import Connection as SonicConnection
ParameterError,
SonicError,
)
-from music_assistant_models.enums import (
- ContentType,
- ImageType,
- MediaType,
- ProviderFeature,
- StreamType,
-)
+from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType
from music_assistant_models.errors import (
ActionUnavailable,
LoginFailed,
Artist,
AudioFormat,
ItemMapping,
- MediaItemImage,
MediaItemType,
Playlist,
Podcast,
Track,
)
from music_assistant_models.streamdetails import StreamDetails
-from music_assistant_models.unique_list import UniqueList
from music_assistant.constants import (
CONF_PASSWORD,
)
from music_assistant.models.music_provider import MusicProvider
-from .parsers import parse_album, parse_artist
+from .parsers import (
+ EP_CHAN_SEP,
+ UNKNOWN_ARTIST_ID,
+ parse_album,
+ parse_artist,
+ parse_epsiode,
+ parse_playlist,
+ parse_podcast,
+)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable
from libopensonic.media import Album as SonicAlbum
from libopensonic.media import Artist as SonicArtist
+ from libopensonic.media import Child as SonicSong
+ from libopensonic.media import OpenSubsonicExtension
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"
CONF_ENABLE_PODCASTS = "enable_podcasts"
CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
CONF_OVERRIDE_OFFSET = "override_transcode_offest"
-UNKNOWN_ARTIST_ID = "fake_artist_unknown"
# We need the following prefix because of the way that Navidrome reports artists for individual
# tracks on Various Artists albums, see the note in the _parse_track() method and the handling
# in get_artist()
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 = "$!$"
-
-
Param = ParamSpec("Param")
RetType = TypeVar("RetType")
self.config.get_value(CONF_BASE_URL),
username=self.config.get_value(CONF_USERNAME),
password=self.config.get_value(CONF_PASSWORD),
- legacyAuth=self.config.get_value(CONF_ENABLE_LEGACY_AUTH),
+ legacy_auth=self.config.get_value(CONF_ENABLE_LEGACY_AUTH),
port=port,
- serverPath=path,
- appName="Music Assistant",
+ server_path=path,
+ app_name="Music Assistant",
)
try:
success = await self._run_async(self._conn.ping)
self._enable_podcasts = bool(self.config.get_value(CONF_ENABLE_PODCASTS))
self._ignore_offset = bool(self.config.get_value(CONF_OVERRIDE_OFFSET))
try:
- ret = await self._run_async(self._conn.getOpenSubsonicExtensions)
- extensions = ret["openSubsonicExtensions"]
+ extensions: list[OpenSubsonicExtension] = await self._run_async(
+ self._conn.get_open_subsonic_extensions
+ )
for entry in extensions:
- if entry["name"] == "transcodeOffset" and not self._ignore_offset:
+ if entry.name == "transcodeOffset" and not self._ignore_offset:
self._seek_support = True
break
except OSError:
),
)
},
- track_number=getattr(sonic_song, "track", 0),
+ track_number=sonic_song.track if sonic_song.track else 0,
)
# We need to find an artist for this track but various implementations seem to disagree
track.artists.append(artist)
return track
- def _parse_playlist(self, sonic_playlist: SonicPlaylist) -> Playlist:
- playlist = Playlist(
- item_id=sonic_playlist.id,
- provider=self.domain,
- name=sonic_playlist.name,
- is_editable=True,
- favorite=bool(sonic_playlist.starred),
- provider_mappings={
- ProviderMapping(
- item_id=sonic_playlist.id,
- provider_domain=self.domain,
- provider_instance=self.instance_id,
- )
- },
- )
-
- if sonic_playlist.cover_id:
- playlist.metadata.images = UniqueList()
- playlist.metadata.images.append(
- MediaItemImage(
- type=ImageType.THUMB,
- path=sonic_playlist.cover_id,
- provider=self.instance_id,
- remotely_accessible=False,
- )
- )
- 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 = UniqueList()
-
- 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
- ) -> PodcastEpisode:
- 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 = PodcastEpisode(
- item_id=eid,
- provider=self.domain,
- name=sonic_episode.title,
- position=pos,
- podcast=self._parse_podcast(sonic_channel),
- provider_mappings={
- ProviderMapping(
- item_id=eid,
- provider_domain=self.domain,
- provider_instance=self.instance_id,
- )
- },
- duration=sonic_episode.duration,
- )
-
- if sonic_episode.publish_date:
- episode.metadata.release_date = datetime.fromisoformat(sonic_episode.publish_date)
-
- if sonic_episode.description:
- episode.metadata.description = sonic_episode.description
-
- 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)
+ chan = await self._run_async(self._conn.get_podcasts, inc_episodes=True, pid=chan_id)
- for episode in chan[0].episodes:
+ for episode in chan[0].episode:
if episode.id == ep_id:
return episode
def _get_cover_art() -> bytes | Any:
try:
- with self._conn.getCoverArt(path) as art:
+ with self._conn.get_cover_art(path) as art:
return art.content
except DataNotFoundError:
self.logger.warning("Unable to locate a cover image for %s", path)
answer = await self._run_async(
self._conn.search3,
query=search_query,
- artistCount=artists,
- artistOffset=0,
- albumCount=albums,
- albumOffset=0,
- songCount=songs,
- songOffset=0,
- musicFolderId=None,
+ artist_count=artists,
+ artist_offset=0,
+ album_count=albums,
+ album_offset=0,
+ song_count=songs,
+ song_offset=0,
+ music_folder_id=None,
)
return SearchResults(
- artists=[parse_artist(self.instance_id, entry) for entry in answer["artists"]],
- albums=[
- parse_album(self.logger, self.instance_id, entry) for entry in answer["albums"]
- ],
- tracks=[self._parse_track(entry) for entry in answer["songs"]],
+ artists=[parse_artist(self.instance_id, entry) for entry in answer.artist]
+ if answer.artist
+ else [],
+ albums=[parse_album(self.logger, self.instance_id, entry) for entry in answer.album]
+ if answer.album
+ else [],
+ tracks=[self._parse_track(entry) for entry in answer.song] if answer.song else [],
)
async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
"""Provide a generator for reading all artists."""
- indices = await self._run_async(self._conn.getArtists)
- for index in indices:
- for artist in index.artists:
+ artists = await self._run_async(self._conn.get_artists)
+ for index in artists.index:
+ for artist in index.artist:
yield parse_artist(self.instance_id, artist)
async def get_library_albums(self) -> AsyncGenerator[Album, None]:
offset = 0
size = 500
albums = await self._run_async(
- self._conn.getAlbumList2,
+ self._conn.get_album_list2,
ltype="alphabeticalByArtist",
size=size,
offset=offset,
yield parse_album(self.logger, self.instance_id, album)
offset += size
albums = await self._run_async(
- self._conn.getAlbumList2,
+ self._conn.get_album_list2,
ltype="alphabeticalByArtist",
size=size,
offset=offset,
async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
"""Provide a generator for library playlists."""
- results = await self._run_async(self._conn.getPlaylists)
+ results = await self._run_async(self._conn.get_playlists)
for entry in results:
- yield self._parse_playlist(entry)
+ yield parse_playlist(self.instance_id, entry)
async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
"""
results = await self._run_async(
self._conn.search3,
query=query,
- artistCount=0,
- albumCount=0,
- songOffset=offset,
- songCount=count,
+ artist_count=0,
+ album_count=0,
+ song_offset=offset,
+ song_count=count,
)
except ParameterError:
# Older Navidrome does not accept an empty string and requires the empty quotes
results = await self._run_async(
self._conn.search3,
query=query,
- artistCount=0,
- albumCount=0,
- songOffset=offset,
- songCount=count,
+ artist_count=0,
+ album_count=0,
+ song_offset=offset,
+ song_count=count,
)
- while results["songs"]:
+ while results.song:
album: Album | None = None
- for entry in results["songs"]:
+ for entry in results.song:
aid = entry.album_id if entry.album_id else entry.parent
if album is None or album.item_id != aid:
album = await self.get_album(prov_album_id=aid)
results = await self._run_async(
self._conn.search3,
query=query,
- artistCount=0,
- albumCount=0,
- songOffset=offset,
- songCount=count,
+ artist_count=0,
+ album_count=0,
+ song_offset=offset,
+ song_count=count,
)
async def get_album(self, prov_album_id: str) -> Album:
"""Return the requested Album."""
try:
- sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id)
- sonic_info = await self._run_async(self._conn.getAlbumInfo2, aid=prov_album_id)
+ sonic_album: SonicAlbum = await self._run_async(self._conn.get_album, prov_album_id)
+ sonic_info = await self._run_async(self._conn.get_album_info2, aid=prov_album_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Album {prov_album_id} not found"
raise MediaNotFoundError(msg) from e
async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
"""Return a list of tracks on the specified Album."""
try:
- sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id)
+ sonic_album: SonicAlbum = await self._run_async(self._conn.get_album, prov_album_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Album {prov_album_id} not found"
raise MediaNotFoundError(msg) from e
tracks = []
- for sonic_song in sonic_album.songs:
+ for sonic_song in sonic_album.song:
tracks.append(self._parse_track(sonic_song))
return tracks
try:
sonic_artist: SonicArtist = await self._run_async(
- self._conn.getArtist, artist_id=prov_artist_id
+ self._conn.get_artist, artist_id=prov_artist_id
)
- sonic_info = await self._run_async(self._conn.getArtistInfo2, aid=prov_artist_id)
+ sonic_info = await self._run_async(self._conn.get_artist_info2, aid=prov_artist_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Artist {prov_artist_id} not found"
raise MediaNotFoundError(msg) from e
async def get_track(self, prov_track_id: str) -> Track:
"""Return the specified track."""
try:
- sonic_song: SonicSong = await self._run_async(self._conn.getSong, prov_track_id)
+ sonic_song: SonicSong = await self._run_async(self._conn.get_song, prov_track_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Item {prov_track_id} not found"
raise MediaNotFoundError(msg) from e
return []
try:
- sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id)
+ sonic_artist: SonicArtist = await self._run_async(self._conn.get_artist, prov_artist_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Album {prov_artist_id} not found"
raise MediaNotFoundError(msg) from e
albums = []
- for entry in sonic_artist.albums:
+ for entry in sonic_artist.album:
albums.append(parse_album(self.logger, self.instance_id, entry))
return albums
"""Return the specified Playlist."""
try:
sonic_playlist: SonicPlaylist = await self._run_async(
- self._conn.getPlaylist, prov_playlist_id
+ self._conn.get_playlist, prov_playlist_id
)
except (ParameterError, DataNotFoundError) as e:
msg = f"Playlist {prov_playlist_id} not found"
raise MediaNotFoundError(msg) from e
- return self._parse_playlist(sonic_playlist)
+ return parse_playlist(self.instance_id, sonic_playlist)
async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
"""Get (full) podcast episode details by id."""
if not self._enable_podcasts:
return
channels = await self._run_async(
- self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+ self._conn.get_podcasts, inc_episodes=True, pid=prov_podcast_id
)
channel = channels[0]
- for episode in channel.episodes:
- yield self._parse_epsiode(episode, channel)
+ for episode in channel.episode:
+ yield parse_epsiode(self.instance_id, episode, channel)
async def get_podcast(self, prov_podcast_id: str) -> Podcast:
"""Get full Podcast details by id."""
raise ActionUnavailable(msg)
channels = await self._run_async(
- self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+ self._conn.get_podcasts, inc_episodes=True, pid=prov_podcast_id
)
- return self._parse_podcast(channels[0])
+ return parse_podcast(self.instance_id, 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)
+ channels = await self._run_async(self._conn.get_podcasts, inc_episodes=True)
for channel in channels:
- yield self._parse_podcast(channel)
+ yield parse_podcast(self.instance_id, channel)
async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
"""Get playlist tracks."""
return result
try:
sonic_playlist: SonicPlaylist = await self._run_async(
- self._conn.getPlaylist, prov_playlist_id
+ self._conn.get_playlist, prov_playlist_id
)
except (ParameterError, DataNotFoundError) as e:
msg = f"Playlist {prov_playlist_id} not found"
raise MediaNotFoundError(msg) from e
+ if not sonic_playlist.entry:
+ return result
+
album: Album | None = None
- for index, sonic_song in enumerate(sonic_playlist.songs, 1):
+ for index, sonic_song in enumerate(sonic_playlist.entry, 1):
aid = sonic_song.album_id if sonic_song.album_id else sonic_song.parent
if not aid:
- self.logger.warning("Unable to find albumd for track %s", sonic_song.id)
+ self.logger.warning("Unable to find album for track %s", sonic_song.id)
if not album or album.item_id != aid:
album = await self.get_album(prov_album_id=aid)
track = self._parse_track(sonic_song, album=album)
return []
try:
- sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id)
+ sonic_artist: SonicArtist = await self._run_async(self._conn.get_artist, prov_artist_id)
except DataNotFoundError as e:
msg = f"Artist {prov_artist_id} not found"
raise MediaNotFoundError(msg) from e
- songs: list[SonicSong] = await self._run_async(self._conn.getTopSongs, sonic_artist.name)
+ songs: list[SonicSong] = await self._run_async(self._conn.get_top_songs, sonic_artist.name)
return [self._parse_track(entry) for entry in songs]
async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
"""Get tracks similar to selected track."""
try:
songs: list[SonicSong] = await self._run_async(
- self._conn.getSimilarSongs, iid=prov_track_id, count=limit
+ self._conn.get_similar_songs, iid=prov_track_id, count=limit
)
except DataNotFoundError as e:
# Subsonic returns an error here instead of an empty list, I don't think this
async def create_playlist(self, name: str) -> Playlist:
"""Create a new empty playlist on the server."""
- playlist: SonicPlaylist = await self._run_async(self._conn.createPlaylist, name=name)
- return self._parse_playlist(playlist)
+ playlist: SonicPlaylist = await self._run_async(self._conn.create_playlist, name=name)
+ return parse_playlist(self.instance_id, playlist)
async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
"""Append the listed tracks to the selected playlist.
"""
try:
await self._run_async(
- self._conn.updatePlaylist,
+ self._conn.update_playlist,
lid=prov_playlist_id,
- songIdsToAdd=prov_track_ids,
+ song_ids_to_add=prov_track_ids,
)
except SonicError as ex:
msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
idx_to_remove = [pos - 1 for pos in positions_to_remove]
try:
await self._run_async(
- self._conn.updatePlaylist,
+ self._conn.update_playlist,
lid=prov_playlist_id,
- songIndexesToRemove=idx_to_remove,
+ song_indices_to_remove=idx_to_remove,
)
except SonicError as ex:
msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
item: SonicSong | SonicEpisode
if media_type == MediaType.TRACK:
try:
- item = await self._run_async(self._conn.getSong, item_id)
+ item = await self._run_async(self._conn.get_song, item_id)
except (ParameterError, DataNotFoundError) as e:
msg = f"Item {item_id} not found"
raise MediaNotFoundError(msg) from e
try:
with self._conn.stream(
streamdetails.item_id,
- timeOffset=seek_position,
- estimateContentLength=True,
+ time_offset=seek_position,
+ estimate_length=True,
) as stream:
for chunk in stream.iter_content(chunk_size=40960):
asyncio.run_coroutine_threadsafe(
pkce==1.0.3
plexapi==4.17.0
podcastparser==0.6.10
-py-opensonic==5.3.1
+py-opensonic==7.0.1
pyblu==2.0.1
PyChromecast==14.0.7
pycryptodome==3.22.0
'version': '',
})
# ---
+# name: test_parse_episode[gonic-sample.episode]
+ dict({
+ 'duration': 1878,
+ 'external_ids': list([
+ ]),
+ 'favorite': False,
+ 'fully_played': None,
+ 'is_playable': True,
+ 'item_id': 'pd-5$!$pe-1860',
+ 'media_type': 'podcast_episode',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': 'The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.',
+ 'explicit': None,
+ 'genres': None,
+ 'images': None,
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lrc_lyrics': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': '2012-05-06T18:18:38+00:00',
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': '179- The End',
+ 'podcast': dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': False,
+ 'is_playable': True,
+ 'item_id': 'pd-5',
+ 'media_type': 'podcast',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': 'A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!',
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'pd-5',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lrc_lyrics': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': 'The History of Rome',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'codec_type': '?',
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': 'pd-5',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'publisher': None,
+ 'sort_name': 'history of rome, the',
+ 'total_episodes': 5,
+ 'translation_key': None,
+ 'uri': 'http://feeds.feedburner.com/TheHistoryOfRome',
+ 'version': '',
+ }),
+ 'position': 5,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'codec_type': '?',
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': 'pd-5$!$pe-1860',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'resume_position_ms': None,
+ 'sort_name': '179- the end',
+ 'translation_key': None,
+ 'uri': 'opensubsonic://podcast_episode/pd-5$!$pe-1860',
+ 'version': '',
+ })
+# ---
+# name: test_parse_playlist[gonic-sample.playlist]
+ dict({
+ 'cache_checksum': None,
+ 'external_ids': list([
+ ]),
+ 'favorite': False,
+ 'is_editable': True,
+ 'is_playable': True,
+ 'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+ 'media_type': 'playlist',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': None,
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'al-2250',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lrc_lyrics': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': "Guns N' Roses - Use Your Illusion",
+ 'owner': '',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'codec_type': '?',
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': "guns n' roses - use your illusion",
+ 'translation_key': None,
+ 'uri': 'opensubsonic://playlist/Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+ 'version': '',
+ })
+# ---
+# name: test_parse_podcast[gonic-sample.podcast]
+ dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': False,
+ 'is_playable': True,
+ 'item_id': 'pd-5',
+ 'media_type': 'podcast',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': 'A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!',
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'pd-5',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lrc_lyrics': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': 'The History of Rome',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'codec_type': '?',
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': 'pd-5',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'publisher': None,
+ 'sort_name': 'history of rome, the',
+ 'total_episodes': 5,
+ 'translation_key': None,
+ 'uri': 'http://feeds.feedburner.com/TheHistoryOfRome',
+ 'version': '',
+ })
+# ---
--- /dev/null
+{
+ "id": "pe-1860",
+ "isDir": false,
+ "title": "179- The End",
+ "parent": "",
+ "year": 2012,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 15032655,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 1878,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1860",
+ "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+ "publishDate": "2012-05-06T18:18:38Z"
+}
--- /dev/null
+{
+ "id": "pd-5",
+ "url": "http://feeds.feedburner.com/TheHistoryOfRome",
+ "status": "skipped",
+ "title": "The History of Rome",
+ "description": "A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!",
+ "coverArt": "pd-5",
+ "originalImageUrl": "https://static.libsyn.com/p/assets/1/2/f/c/12fc067020662e91/THoR_Logo_1500x1500.jpg",
+ "episode": [
+ {
+ "id": "pe-4805",
+ "isDir": false,
+ "title": "Ad-Free History of Rome Patreon",
+ "parent": "",
+ "year": 2024,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 1717520,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 71,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-4805",
+ "description": "Become a patron and get the entire History of Rome backcatalog ad-fre, plus bonus content, behind the scenes peeks at the new book, plus a chat community where you can talk to me directly. Join today! Patreon: patreon.com/thehistoryofrome Merch Store: cottonbureau.com/mikeduncan",
+ "publishDate": "2024-11-05T02:35:00Z"
+ },
+ {
+ "id": "pe-1857",
+ "isDir": false,
+ "title": "The Storm Before The Storm: Chapter 1- The Beasts of Italy",
+ "parent": "",
+ "year": 2017,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 80207374,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 3340,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1857",
+ "description": "Audio excerpt from The Storm Before the Storm: The Beginning of the End of the Roman Republic by Mike Duncan. Forthcoming Oct. 24, 2017. Pre-order a copy today! Amazon Powells Barnes & Noble Indibound Books-a-Million Or visit us at: revolutionspodcast.com thehistoryofrome.com",
+ "publishDate": "2017-07-27T11:30:00Z"
+ },
+ {
+ "id": "pe-1858",
+ "isDir": false,
+ "title": "Revolutions Launch",
+ "parent": "",
+ "year": 2013,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 253200,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 15,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1858",
+ "description": "Available at revolutionspodcast.com, iTunes, or anywhere else fine podcasts can be found.",
+ "publishDate": "2013-09-16T15:39:57Z"
+ },
+ {
+ "id": "pe-1859",
+ "isDir": false,
+ "title": "Update- One Year Later",
+ "parent": "",
+ "year": 2013,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 1588998,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 99,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1859",
+ "description": "Next show coming soon!",
+ "publishDate": "2013-05-30T15:18:57Z"
+ },
+ {
+ "id": "pe-1860",
+ "isDir": false,
+ "title": "179- The End",
+ "parent": "",
+ "year": 2012,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 15032655,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 1878,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1860",
+ "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+ "publishDate": "2012-05-06T18:18:38Z"
+ }
+ ]
+}
--- /dev/null
+{
+ "id": "Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1",
+ "name": "Guns N' Roses - Use Your Illusion",
+ "songCount": 16,
+ "coverArt": "al-2250",
+ "duration": 5268,
+ "created": "2025-04-04T18:40:41.760062653Z",
+ "changed": "2025-04-04T18:40:41.760062653Z",
+ "comment": "",
+ "owner": "user",
+ "public": true,
+ "entry": [
+ {
+ "id": "tr-16009",
+ "isDir": false,
+ "title": "Right Next Door to Hell",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 1,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 5938059,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 182,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/01 Right Next Door to Hell.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.574907642Z",
+ "type": "music",
+ "musicBrainzId": "471c43b3-9efb-4557-aef3-4ea8465b8390",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16010",
+ "isDir": false,
+ "title": "Dust N\\u2019 Bones",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 2,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 9655740,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 298,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/02 Dust N\\u2019 Bones.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.586200432Z",
+ "type": "music",
+ "musicBrainzId": "982fde94-99b8-4743-bb61-a5c008c024ca",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16011",
+ "isDir": false,
+ "title": "Live and Let Die",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 3,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 5992442,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 184,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/03 Live and Let Die.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.596262286Z",
+ "type": "music",
+ "musicBrainzId": "a13ba277-a7ed-44bb-9e42-598a5983e7f0",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16012",
+ "isDir": false,
+ "title": "Don\\u2019t Cry (original)",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 4,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 9222317,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 284,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/04 Don\\u2019t Cry (original).mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.613645878Z",
+ "type": "music",
+ "musicBrainzId": "3b6271d3-e0ad-408b-8886-d72de1ef783e",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16013",
+ "isDir": false,
+ "title": "Perfect Crime",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 5,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 4698806,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 143,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/05 Perfect Crime.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.622707628Z",
+ "type": "music",
+ "musicBrainzId": "aa4ae4e7-94df-4c08-b0a0-514c050554ba",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16018",
+ "isDir": false,
+ "title": "November Rain",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 10,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 17316320,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 537,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/10 November Rain.mp3",
+ "isVideo": false,
+ "averageRating": 5.0,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.669479868Z",
+ "type": "music",
+ "musicBrainzId": "0681520a-6499-4cb8-91aa-7df5dfafea2e",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16019",
+ "isDir": false,
+ "title": "The Garden",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 11,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 10420531,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 322,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/11 The Garden.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.67743051Z",
+ "type": "music",
+ "musicBrainzId": "c76a6a6e-1861-4e7c-8ded-4f3247804328",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16023",
+ "isDir": false,
+ "title": "Dead Horse",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 15,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 8349620,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 257,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/15 Dead Horse.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.716120687Z",
+ "type": "music",
+ "musicBrainzId": "51f73984-da17-48b2-b83d-ecd867b8325e",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16025",
+ "isDir": false,
+ "title": "Civil War",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 1,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 14891547,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 462,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/01 Civil War.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.747021832Z",
+ "type": "music",
+ "musicBrainzId": "24cbe6e3-50bb-487c-b477-eee77d268556",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16026",
+ "isDir": false,
+ "title": "14 Years",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 2,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 8461921,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 261,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/02 14 Years.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.756107511Z",
+ "type": "music",
+ "musicBrainzId": "a7263180-522d-4920-9c7f-b2a02671218d",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16027",
+ "isDir": false,
+ "title": "Yesterdays",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 3,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 6375055,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 196,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/03 Yesterdays.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.765576962Z",
+ "type": "music",
+ "musicBrainzId": "ed8ced59-66bb-4497-8ba9-d6bab7f1e950",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16028",
+ "isDir": false,
+ "title": "Knockin\\u2019 on Heaven\\u2019s Door",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 4,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 10851837,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 336,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/04 Knockin\\u2019 on Heaven\\u2019s Door.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.777119143Z",
+ "type": "music",
+ "musicBrainzId": "60e32de8-b6f7-4919-9f57-ba9c9974cf89",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16035",
+ "isDir": false,
+ "title": "Estranged",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 11,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 18152440,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 563,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/11 Estranged.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.847816462Z",
+ "type": "music",
+ "musicBrainzId": "85e01d1e-34b1-413a-8b20-bc4c0db56fb6",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16036",
+ "isDir": false,
+ "title": "You Could Be Mine",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 12,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 11096880,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 343,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/12 You Could Be Mine.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.857372733Z",
+ "type": "music",
+ "musicBrainzId": "795602a9-94f5-43f5-bb87-d058ac9428ec",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16037",
+ "isDir": false,
+ "title": "Don\\u2019t Cry (alternate lyrics)",
+ "parent": "al-2251",
+ "album": "Use Your Illusion II",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 13,
+ "year": 1991,
+ "coverArt": "al-2251",
+ "size": 9194959,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 284,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion II/13 Don\\u2019t Cry (alternate lyrics).mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.865879798Z",
+ "type": "music",
+ "musicBrainzId": "de5479b3-522f-450f-a4d3-6636f593e5e3",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ },
+ {
+ "id": "tr-16024",
+ "isDir": false,
+ "title": "Coma",
+ "parent": "al-2250",
+ "album": "Use Your Illusion I",
+ "artist": "Guns N\\u2019 Roses",
+ "track": 16,
+ "year": 1991,
+ "coverArt": "al-2250",
+ "size": 20014868,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 616,
+ "bitRate": 256,
+ "path": "Guns N\\u2019 Roses/Use Your Illusion I/16 Coma.mp3",
+ "isVideo": false,
+ "discNumber": 1,
+ "created": "2024-08-18T03:38:39.726021Z",
+ "type": "music",
+ "musicBrainzId": "2146c769-d454-435d-a924-7960f9272540",
+ "artists": [
+ {
+ "id": "ar-280",
+ "name": "Guns N\\u2019 Roses"
+ }
+ ],
+ "displayArtist": "",
+ "displayAlbumArtist": ""
+ }
+ ]
+}
--- /dev/null
+{
+ "id": "pd-5",
+ "url": "http://feeds.feedburner.com/TheHistoryOfRome",
+ "status": "skipped",
+ "title": "The History of Rome",
+ "description": "A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!",
+ "coverArt": "pd-5",
+ "originalImageUrl": "https://static.libsyn.com/p/assets/1/2/f/c/12fc067020662e91/THoR_Logo_1500x1500.jpg",
+ "episode": [
+ {
+ "id": "pe-4805",
+ "isDir": false,
+ "title": "Ad-Free History of Rome Patreon",
+ "parent": "",
+ "year": 2024,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 1717520,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 71,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-4805",
+ "description": "Become a patron and get the entire History of Rome backcatalog ad-fre, plus bonus content, behind the scenes peeks at the new book, plus a chat community where you can talk to me directly. Join today! Patreon: patreon.com/thehistoryofrome Merch Store: cottonbureau.com/mikeduncan",
+ "publishDate": "2024-11-05T02:35:00Z"
+ },
+ {
+ "id": "pe-1857",
+ "isDir": false,
+ "title": "The Storm Before The Storm: Chapter 1- The Beasts of Italy",
+ "parent": "",
+ "year": 2017,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 80207374,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 3340,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1857",
+ "description": "Audio excerpt from The Storm Before the Storm: The Beginning of the End of the Roman Republic by Mike Duncan. Forthcoming Oct. 24, 2017. Pre-order a copy today! Amazon Powells Barnes & Noble Indibound Books-a-Million Or visit us at: revolutionspodcast.com thehistoryofrome.com",
+ "publishDate": "2017-07-27T11:30:00Z"
+ },
+ {
+ "id": "pe-1858",
+ "isDir": false,
+ "title": "Revolutions Launch",
+ "parent": "",
+ "year": 2013,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 253200,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 15,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1858",
+ "description": "Available at revolutionspodcast.com, iTunes, or anywhere else fine podcasts can be found.",
+ "publishDate": "2013-09-16T15:39:57Z"
+ },
+ {
+ "id": "pe-1859",
+ "isDir": false,
+ "title": "Update- One Year Later",
+ "parent": "",
+ "year": 2013,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 1588998,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 99,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1859",
+ "description": "Next show coming soon!",
+ "publishDate": "2013-05-30T15:18:57Z"
+ },
+ {
+ "id": "pe-1860",
+ "isDir": false,
+ "title": "179- The End",
+ "parent": "",
+ "year": 2012,
+ "genre": "Podcast",
+ "coverArt": "pd-5",
+ "size": 15032655,
+ "contentType": "audio/mpeg",
+ "suffix": "mp3",
+ "duration": 1878,
+ "path": "",
+ "channelId": "pd-5",
+ "status": "completed",
+ "streamId": "pe-1860",
+ "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+ "publishDate": "2012-05-06T18:18:38Z"
+ }
+ ]
+}
-"""Test we can parse Jellyfin models into Music Assistant models."""
+"""Test we can parse Open Subsonic models into Music Assistant models."""
-import json
import logging
import pathlib
import aiofiles
import pytest
-from libopensonic.media.album import Album, AlbumInfo
-from libopensonic.media.artist import Artist, ArtistInfo
+from libopensonic.media import (
+ Album,
+ AlbumInfo,
+ Artist,
+ ArtistInfo,
+ Playlist,
+ PodcastChannel,
+ PodcastEpisode,
+)
from syrupy.assertion import SnapshotAssertion
-from music_assistant.providers.opensubsonic.parsers import parse_album, parse_artist
+from music_assistant.providers.opensubsonic.parsers import (
+ parse_album,
+ parse_artist,
+ parse_epsiode,
+ parse_playlist,
+ parse_podcast,
+)
FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.artist.json"))
ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.album.json"))
+PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.playlist.json"))
+PODCAST_FIXTURES = list(FIXTURES_DIR.glob("podcasts/*.podcast.json"))
+EPISODE_FIXTURES = list(FIXTURES_DIR.glob("episodes/*.episode.json"))
_LOGGER = logging.getLogger(__name__)
async def test_parse_artists(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
"""Test we can parse artists."""
async with aiofiles.open(example) as fp:
- artist = Artist(json.loads(await fp.read()))
+ artist = Artist.from_json(await fp.read())
parsed = parse_artist("xx-instance-id-xx", artist).to_dict()
# sort external Ids to ensure they are always in the same order for snapshot testing
# Find the corresponding info file
example_info = example.with_suffix("").with_suffix(".info.json")
async with aiofiles.open(example_info) as fp:
- artist_info = ArtistInfo(json.loads(await fp.read()))
+ artist_info = ArtistInfo.from_json(await fp.read())
parsed = parse_artist("xx-instance-id-xx", artist, artist_info).to_dict()
# sort external Ids to ensure they are always in the same order for snapshot testing
async def test_parse_albums(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
"""Test we can parse albums."""
async with aiofiles.open(example) as fp:
- album = Album(json.loads(await fp.read()))
+ album = Album.from_json(await fp.read())
parsed = parse_album(_LOGGER, "xx-instance-id-xx", album).to_dict()
# sort external Ids to ensure they are always in the same order for snapshot testing
# Find the corresponding info file
example_info = example.with_suffix("").with_suffix(".info.json")
async with aiofiles.open(example_info) as fp:
- album_info = AlbumInfo(json.loads(await fp.read()))
+ album_info = AlbumInfo.from_json(await fp.read())
parsed = parse_album(_LOGGER, "xx-instance-id-xx", album, album_info).to_dict()
# sort external Ids to ensure they are always in the same order for snapshot testing
parsed["external_ids"].sort()
assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_playlist(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+ """Test we can parse Playlists."""
+ async with aiofiles.open(example) as fp:
+ playlist = Playlist.from_json(await fp.read())
+
+ parsed = parse_playlist("xx-instance-id-xx", playlist).to_dict()
+ # sort external Ids to ensure they are always in the same order for snapshot testing
+ parsed["external_ids"].sort()
+ assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", PODCAST_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_podcast(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+ """Test we can parse Podcasts."""
+ async with aiofiles.open(example) as fp:
+ podcast = PodcastChannel.from_json(await fp.read())
+
+ parsed = parse_podcast("xx-instance-id-xx", podcast).to_dict()
+ # sort external Ids to ensure they are always in the same order for snapshot testing
+ parsed["external_ids"].sort()
+ assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", EPISODE_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_episode(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+ """Test we can parse Podcast Episodes."""
+ async with aiofiles.open(example) as fp:
+ episode = PodcastEpisode.from_json(await fp.read())
+
+ example_channel = example.with_suffix("").with_suffix(".podcast.json")
+ async with aiofiles.open(example_channel) as fp:
+ channel = PodcastChannel.from_json(await fp.read())
+
+ parsed = parse_epsiode("xx-instance-id-xx", episode, channel).to_dict()
+ # sort external Ids to ensure they are always in the same order for snapshot testing
+ parsed["external_ids"].sort()
+ assert snapshot == parsed