diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a65b6cbcc..445b49563 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: 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 diff --git a/music_assistant/providers/opensubsonic/parsers.py b/music_assistant/providers/opensubsonic/parsers.py new file mode 100644 index 000000000..bff34b265 --- /dev/null +++ b/music_assistant/providers/opensubsonic/parsers.py @@ -0,0 +1,58 @@ +"""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 diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index ab814766b..ba6e66f5b 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -52,13 +52,14 @@ ) 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 @@ -178,48 +179,6 @@ def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemM 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( @@ -525,7 +484,7 @@ async def search( 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"]], ) @@ -535,7 +494,7 @@ async def get_library_artists(self) -> AsyncGenerator[Artist, None]: 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]: """ @@ -675,7 +634,7 @@ async def get_artist(self, prov_artist_id: str) -> Artist: 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.""" diff --git a/tests/providers/opensubsonic/__init__.py b/tests/providers/opensubsonic/__init__.py new file mode 100644 index 000000000..ec2458232 --- /dev/null +++ b/tests/providers/opensubsonic/__init__.py @@ -0,0 +1 @@ +"""Tests for opensubsonic.""" diff --git a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr new file mode 100644 index 000000000..746a1ce09 --- /dev/null +++ b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr @@ -0,0 +1,253 @@ +# 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': '', + }) +# --- diff --git a/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.artist.json b/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.artist.json new file mode 100644 index 000000000..fc58f8cd2 --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.artist.json @@ -0,0 +1,16 @@ +{ + "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" + ] +} diff --git a/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.info.json b/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.info.json new file mode 100644 index 000000000..8e64ec6cc --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/artists/spec-artistid3.info.json @@ -0,0 +1,7 @@ +{ + "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" +} diff --git a/tests/providers/opensubsonic/fixtures/artists/spec-sample.artist.json b/tests/providers/opensubsonic/fixtures/artists/spec-sample.artist.json new file mode 100644 index 000000000..0d5076601 --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/artists/spec-sample.artist.json @@ -0,0 +1,7 @@ +{ + "id": "100000002", + "name": "Synthetic", + "coverArt": "ar-100000002", + "albumCount": 1, + "starred": "2021-02-22T05:54:18Z" +} diff --git a/tests/providers/opensubsonic/fixtures/artists/spec-sample.info.json b/tests/providers/opensubsonic/fixtures/artists/spec-sample.info.json new file mode 100644 index 000000000..8e64ec6cc --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/artists/spec-sample.info.json @@ -0,0 +1,7 @@ +{ + "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" +} diff --git a/tests/providers/opensubsonic/test_parsers.py b/tests/providers/opensubsonic/test_parsers.py new file mode 100644 index 000000000..c15f1dd48 --- /dev/null +++ b/tests/providers/opensubsonic/test_parsers.py @@ -0,0 +1,36 @@ +"""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