Opensubsonic tests for artist 'parsing' (#1859)
authorJc2k <john.carr@unrouted.co.uk>
Mon, 13 Jan 2025 15:50:38 +0000 (15:50 +0000)
committerGitHub <noreply@github.com>
Mon, 13 Jan 2025 15:50:38 +0000 (16:50 +0100)
* chore: add parsing test for opensubsonic artist

* chore: make sure test json is covered by pre-commit

* chore: tidy json

.pre-commit-config.yaml
music_assistant/providers/opensubsonic/parsers.py [new file with mode: 0644]
music_assistant/providers/opensubsonic/sonic_provider.py
tests/providers/opensubsonic/__init__.py [new file with mode: 0644]
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/artists/spec-artistid3.artist.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/artists/spec-artistid3.info.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/artists/spec-sample.artist.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/artists/spec-sample.info.json [new file with mode: 0644]
tests/providers/opensubsonic/test_parsers.py [new file with mode: 0644]

index a65b6cbcc2c86f3aaf09c3c4a4ca7288e72d3cd5..445b49563668eae584d1d9b238dbae687d5cc518 100644 (file)
@@ -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 (file)
index 0000000..bff34b2
--- /dev/null
@@ -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
index ab814766b5aa05a34ebb8c551ce49b65c60d3415..ba6e66f5bd945cfaa48beb26a88ff933e1e471de 100644 (file)
@@ -52,13 +52,14 @@ from music_assistant.constants import (
 )
 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 @@ class OpenSonicProvider(MusicProvider):
             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 @@ class OpenSonicProvider(MusicProvider):
             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 @@ class OpenSonicProvider(MusicProvider):
         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 @@ class OpenSonicProvider(MusicProvider):
         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 (file)
index 0000000..ec24582
--- /dev/null
@@ -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 (file)
index 0000000..746a1ce
--- /dev/null
@@ -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 (file)
index 0000000..fc58f8c
--- /dev/null
@@ -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 (file)
index 0000000..8e64ec6
--- /dev/null
@@ -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 (file)
index 0000000..0d50766
--- /dev/null
@@ -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 (file)
index 0000000..8e64ec6
--- /dev/null
@@ -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 (file)
index 0000000..c15f1dd
--- /dev/null
@@ -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