language: system
types: [json]
entry: scripts/run-in-env.sh check-json
- files: ^(music_assistant/.+/manifest\.json)$
+ files: ^(music_assistant/.+/manifest\.json)|(tests/providers/.+/fixtures/.+\.json)$
- id: check-merge-conflict
name: 💥 Check for merge conflicts
language: system
--- /dev/null
+"""Parse objects from py-opensonic into Music Assistant types."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import ImageType
+from music_assistant_models.media_items import Artist, MediaItemImage, ProviderMapping
+from music_assistant_models.unique_list import UniqueList
+
+if TYPE_CHECKING:
+ from libopensonic.media import Artist as SonicArtist
+ from libopensonic.media import ArtistInfo as SonicArtistInfo
+
+
+def parse_artist(
+ instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
+) -> Artist:
+ """Parse artist and artistInfo into a Music Assistant Artist."""
+ artist = Artist(
+ item_id=sonic_artist.id,
+ name=sonic_artist.name,
+ provider="opensubsonic",
+ favorite=bool(sonic_artist.starred),
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_artist.id,
+ provider_domain="opensubsonic",
+ provider_instance=instance_id,
+ )
+ },
+ )
+
+ artist.metadata.images = UniqueList()
+ if sonic_artist.cover_id:
+ artist.metadata.images.append(
+ MediaItemImage(
+ type=ImageType.THUMB,
+ path=sonic_artist.cover_id,
+ provider=instance_id,
+ remotely_accessible=False,
+ )
+ )
+
+ if sonic_info:
+ if sonic_info.biography:
+ artist.metadata.description = sonic_info.biography
+ if sonic_info.small_url:
+ artist.metadata.images.append(
+ MediaItemImage(
+ type=ImageType.THUMB,
+ path=sonic_info.small_url,
+ provider=instance_id,
+ remotely_accessible=True,
+ )
+ )
+
+ return artist
)
from music_assistant.models.music_provider import MusicProvider
+from .parsers import parse_artist
+
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable
from libopensonic.media import Album as SonicAlbum
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
name=name,
)
- def _parse_artist(
- self, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
- ) -> Artist:
- artist = Artist(
- item_id=sonic_artist.id,
- name=sonic_artist.name,
- provider=self.domain,
- favorite=bool(sonic_artist.starred),
- provider_mappings={
- ProviderMapping(
- item_id=sonic_artist.id,
- provider_domain=self.domain,
- provider_instance=self.instance_id,
- )
- },
- )
-
- artist.metadata.images = UniqueList()
- if sonic_artist.cover_id:
- artist.metadata.images.append(
- MediaItemImage(
- type=ImageType.THUMB,
- path=sonic_artist.cover_id,
- provider=self.instance_id,
- remotely_accessible=False,
- )
- )
-
- if sonic_info:
- if sonic_info.biography:
- artist.metadata.description = sonic_info.biography
- if sonic_info.small_url:
- artist.metadata.images.append(
- MediaItemImage(
- type=ImageType.THUMB,
- path=sonic_info.small_url,
- provider=self.instance_id,
- remotely_accessible=True,
- )
- )
- return artist
-
def _parse_album(self, sonic_album: SonicAlbum, sonic_info: SonicAlbumInfo = None) -> Album:
album_id = sonic_album.id
album = Album(
musicFolderId=None,
)
return SearchResults(
- artists=[self._parse_artist(entry) for entry in answer["artists"]],
+ artists=[parse_artist(self.instance_id, entry) for entry in answer["artists"]],
albums=[self._parse_album(entry) for entry in answer["albums"]],
tracks=[self._parse_track(entry) for entry in answer["songs"]],
)
indices = await self._run_async(self._conn.getArtists)
for index in indices:
for artist in index.artists:
- yield self._parse_artist(artist)
+ yield parse_artist(self.instance_id, artist)
async def get_library_albums(self) -> AsyncGenerator[Album, None]:
"""
except (ParameterError, DataNotFoundError) as e:
msg = f"Artist {prov_artist_id} not found"
raise MediaNotFoundError(msg) from e
- return self._parse_artist(sonic_artist, sonic_info)
+ return parse_artist(self.instance_id, sonic_artist, sonic_info)
async def get_track(self, prov_track_id: str) -> Track:
"""Return the specified track."""
--- /dev/null
+"""Tests for opensubsonic."""
--- /dev/null
+# serializer version: 1
+# name: test_parse_artists[spec-artistid3.artist]
+ dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': True,
+ 'item_id': '37ec820ca7193e17040c98f7da7c4b51',
+ 'media_type': 'artist',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': None,
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'ar-37ec820ca7193e17040c98f7da7c4b51_0',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': '2 Mello',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': '37ec820ca7193e17040c98f7da7c4b51',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': '2 mello',
+ 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
+ 'version': '',
+ })
+# ---
+# name: test_parse_artists[spec-artistid3.artist].1
+ dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': True,
+ 'item_id': '37ec820ca7193e17040c98f7da7c4b51',
+ 'media_type': 'artist',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': 'Empty biography',
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'ar-37ec820ca7193e17040c98f7da7c4b51_0',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ dict({
+ 'path': 'http://localhost:8989/play/art/f20070e8e11611cc53542a38801d60fa/artist/2/thumb34.jpg',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': True,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': '2 Mello',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': '37ec820ca7193e17040c98f7da7c4b51',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': '2 mello',
+ 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
+ 'version': '',
+ })
+# ---
+# name: test_parse_artists[spec-sample.artist]
+ dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': True,
+ 'item_id': '100000002',
+ 'media_type': 'artist',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': None,
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'ar-100000002',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': 'Synthetic',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': '100000002',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': 'synthetic',
+ 'uri': 'opensubsonic://artist/100000002',
+ 'version': '',
+ })
+# ---
+# name: test_parse_artists[spec-sample.artist].1
+ dict({
+ 'external_ids': list([
+ ]),
+ 'favorite': True,
+ 'item_id': '100000002',
+ 'media_type': 'artist',
+ 'metadata': dict({
+ 'chapters': None,
+ 'copyright': None,
+ 'description': 'Empty biography',
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'ar-100000002',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ dict({
+ 'path': 'http://localhost:8989/play/art/f20070e8e11611cc53542a38801d60fa/artist/2/thumb34.jpg',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': True,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'languages': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'release_date': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': 'Synthetic',
+ 'position': None,
+ 'provider': 'opensubsonic',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 0,
+ 'channels': 2,
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': '100000002',
+ 'provider_domain': 'opensubsonic',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': 'synthetic',
+ 'uri': 'opensubsonic://artist/100000002',
+ 'version': '',
+ })
+# ---
--- /dev/null
+{
+ "id": "37ec820ca7193e17040c98f7da7c4b51",
+ "name": "2 Mello",
+ "coverArt": "ar-37ec820ca7193e17040c98f7da7c4b51_0",
+ "albumCount": 1,
+ "userRating": 5,
+ "artistImageUrl": "https://demo.org/image.jpg",
+ "starred": "2017-04-11T10:42:50.842Z",
+ "musicBrainzId": "189002e7-3285-4e2e-92a3-7f6c30d407a2",
+ "sortName": "Mello (2)",
+ "roles": [
+ "artist",
+ "albumartist",
+ "composer"
+ ]
+}
--- /dev/null
+{
+ "biography": "Empty biography",
+ "musicBrainzId": "1",
+ "smallImageUrl": "http://localhost:8989/play/art/f20070e8e11611cc53542a38801d60fa/artist/2/thumb34.jpg",
+ "mediumImageUrl": "http://localhost:8989/play/art/2b9b6c057cd4bf21089ce7572e7792b6/artist/2/thumb64.jpg",
+ "largeImageUrl": "http://localhost:8989/play/art/e18287c23a75e263b64c31b3d64c1944/artist/2/thumb174.jpg"
+}
--- /dev/null
+{
+ "id": "100000002",
+ "name": "Synthetic",
+ "coverArt": "ar-100000002",
+ "albumCount": 1,
+ "starred": "2021-02-22T05:54:18Z"
+}
--- /dev/null
+{
+ "biography": "Empty biography",
+ "musicBrainzId": "1",
+ "smallImageUrl": "http://localhost:8989/play/art/f20070e8e11611cc53542a38801d60fa/artist/2/thumb34.jpg",
+ "mediumImageUrl": "http://localhost:8989/play/art/2b9b6c057cd4bf21089ce7572e7792b6/artist/2/thumb64.jpg",
+ "largeImageUrl": "http://localhost:8989/play/art/e18287c23a75e263b64c31b3d64c1944/artist/2/thumb174.jpg"
+}
--- /dev/null
+"""Test we can parse Jellyfin models into Music Assistant models."""
+
+import json
+import pathlib
+
+import aiofiles
+import pytest
+from libopensonic.media.artist import Artist, ArtistInfo
+from syrupy.assertion import SnapshotAssertion
+
+from music_assistant.providers.opensubsonic.parsers import parse_artist
+
+FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
+ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.artist.json"))
+
+
+@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: str(val.stem))
+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()))
+
+ 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
+ parsed["external_ids"].sort()
+ assert snapshot == parsed
+
+ # 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()))
+
+ 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
+ parsed["external_ids"].sort()
+ assert snapshot == parsed