chore: add album parsing tests for opensubsonic (#1865)
authorJc2k <john.carr@unrouted.co.uk>
Mon, 13 Jan 2025 23:33:32 +0000 (23:33 +0000)
committerGitHub <noreply@github.com>
Mon, 13 Jan 2025 23:33:32 +0000 (00:33 +0100)
music_assistant/providers/opensubsonic/parsers.py
music_assistant/providers/opensubsonic/sonic_provider.py
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr
tests/providers/opensubsonic/fixtures/albums/spec.album.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/albums/spec.info.json [new file with mode: 0644]
tests/providers/opensubsonic/test_parsers.py

index bff34b2650beea36a3fedb2e21ef26f16cbf069e..a9c2930878a5e0386a06bb4d231d9c5d156418ba 100644 (file)
@@ -2,16 +2,32 @@
 
 from __future__ import annotations
 
+import logging
 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.enums import (
+    ImageType,
+    MediaType,
+)
+from music_assistant_models.media_items import (
+    Album,
+    Artist,
+    ItemMapping,
+    MediaItemImage,
+    ProviderMapping,
+)
 from music_assistant_models.unique_list import UniqueList
 
+from music_assistant.constants import UNKNOWN_ARTIST
+
 if TYPE_CHECKING:
+    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
 
+UNKNOWN_ARTIST_ID = "fake_artist_unknown"
+
 
 def parse_artist(
     instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
@@ -56,3 +72,83 @@ def parse_artist(
             )
 
     return artist
+
+
+def parse_album(
+    logger: logging.Logger,
+    instance_id: str,
+    sonic_album: SonicAlbum,
+    sonic_info: SonicAlbumInfo | None = None,
+) -> Album:
+    """Parse album and albumInfo into a Music Assistant Album."""
+    album_id = sonic_album.id
+    album = Album(
+        item_id=album_id,
+        provider="opensubsonic",
+        name=sonic_album.name,
+        favorite=bool(sonic_album.starred),
+        provider_mappings={
+            ProviderMapping(
+                item_id=album_id,
+                provider_domain="opensubsonic",
+                provider_instance=instance_id,
+            )
+        },
+        year=sonic_album.year,
+    )
+
+    album.metadata.images = UniqueList()
+    if sonic_album.cover_id:
+        album.metadata.images.append(
+            MediaItemImage(
+                type=ImageType.THUMB,
+                path=sonic_album.cover_id,
+                provider=instance_id,
+                remotely_accessible=False,
+            ),
+        )
+
+    if sonic_album.artist_id:
+        album.artists.append(
+            ItemMapping(
+                media_type=MediaType.ARTIST,
+                item_id=sonic_album.artist_id,
+                provider=instance_id,
+                name=sonic_album.artist if sonic_album.artist else UNKNOWN_ARTIST,
+            )
+        )
+    else:
+        logger.info(
+            "Unable to find an artist ID for album '%s' with ID '%s'.",
+            sonic_album.name,
+            sonic_album.id,
+        )
+        album.artists.append(
+            Artist(
+                item_id=UNKNOWN_ARTIST_ID,
+                name=UNKNOWN_ARTIST,
+                provider=instance_id,
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=UNKNOWN_ARTIST_ID,
+                        provider_domain="opensubsonic",
+                        provider_instance=instance_id,
+                    )
+                },
+            )
+        )
+
+    if sonic_info:
+        if sonic_info.small_url:
+            album.metadata.images.append(
+                MediaItemImage(
+                    type=ImageType.THUMB,
+                    path=sonic_info.small_url,
+                    remotely_accessible=False,
+                    provider=instance_id,
+                )
+            )
+        if sonic_info.notes:
+            album.metadata.description = sonic_info.notes
+
+    return album
index ba6e66f5bd945cfaa48beb26a88ff933e1e471de..9b9207cfc4fe819c18feee5c623a700e23d9eb69 100644 (file)
@@ -52,13 +52,12 @@ from music_assistant.constants import (
 )
 from music_assistant.models.music_provider import MusicProvider
 
-from .parsers import parse_artist
+from .parsers import parse_album, 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 Playlist as SonicPlaylist
     from libopensonic.media import PodcastChannel as SonicPodcast
@@ -179,78 +178,6 @@ class OpenSonicProvider(MusicProvider):
             name=name,
         )
 
-    def _parse_album(self, sonic_album: SonicAlbum, sonic_info: SonicAlbumInfo = None) -> Album:
-        album_id = sonic_album.id
-        album = Album(
-            item_id=album_id,
-            provider=self.domain,
-            name=sonic_album.name,
-            favorite=bool(sonic_album.starred),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=album_id,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-            year=sonic_album.year,
-        )
-
-        album.metadata.images = UniqueList()
-        if sonic_album.cover_id:
-            album.metadata.images.append(
-                MediaItemImage(
-                    type=ImageType.THUMB,
-                    path=sonic_album.cover_id,
-                    provider=self.instance_id,
-                    remotely_accessible=False,
-                ),
-            )
-
-        if sonic_album.artist_id:
-            album.artists.append(
-                self._get_item_mapping(
-                    MediaType.ARTIST,
-                    sonic_album.artist_id,
-                    sonic_album.artist if sonic_album.artist else UNKNOWN_ARTIST,
-                )
-            )
-        else:
-            self.logger.info(
-                "Unable to find an artist ID for album '%s' with ID '%s'.",
-                sonic_album.name,
-                sonic_album.id,
-            )
-            album.artists.append(
-                Artist(
-                    item_id=UNKNOWN_ARTIST_ID,
-                    name=UNKNOWN_ARTIST,
-                    provider=self.instance_id,
-                    provider_mappings={
-                        ProviderMapping(
-                            item_id=UNKNOWN_ARTIST_ID,
-                            provider_domain=self.domain,
-                            provider_instance=self.instance_id,
-                        )
-                    },
-                )
-            )
-
-        if sonic_info:
-            if sonic_info.small_url:
-                album.metadata.images.append(
-                    MediaItemImage(
-                        type=ImageType.THUMB,
-                        path=sonic_info.small_url,
-                        remotely_accessible=False,
-                        provider=self.instance_id,
-                    )
-                )
-            if sonic_info.notes:
-                album.metadata.description = sonic_info.notes
-
-        return album
-
     def _parse_track(
         self, sonic_song: SonicSong, album: Album | ItemMapping | None = None
     ) -> Track:
@@ -485,7 +412,9 @@ class OpenSonicProvider(MusicProvider):
         )
         return SearchResults(
             artists=[parse_artist(self.instance_id, entry) for entry in answer["artists"]],
-            albums=[self._parse_album(entry) for entry in answer["albums"]],
+            albums=[
+                parse_album(self.logger, self.instance_id, entry) for entry in answer["albums"]
+            ],
             tracks=[self._parse_track(entry) for entry in answer["songs"]],
         )
 
@@ -513,7 +442,7 @@ class OpenSonicProvider(MusicProvider):
         )
         while albums:
             for album in albums:
-                yield self._parse_album(album)
+                yield parse_album(self.logger, self.instance_id, album)
             offset += size
             albums = await self._run_async(
                 self._conn.getAlbumList2,
@@ -582,7 +511,7 @@ class OpenSonicProvider(MusicProvider):
             msg = f"Album {prov_album_id} not found"
             raise MediaNotFoundError(msg) from e
 
-        return self._parse_album(sonic_album, sonic_info)
+        return parse_album(self.logger, self.instance_id, sonic_album, sonic_info)
 
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Return a list of tracks on the specified Album."""
@@ -658,7 +587,7 @@ class OpenSonicProvider(MusicProvider):
             raise MediaNotFoundError(msg) from e
         albums = []
         for entry in sonic_artist.albums:
-            albums.append(self._parse_album(entry))
+            albums.append(parse_album(self.logger, self.instance_id, entry))
         return albums
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
index 746a1ce0942f52c0c361b26ad16d055c7b462736..11a9438d414ecea529f6e7f2583bde6c6a0a760e 100644 (file)
@@ -1,4 +1,164 @@
 # serializer version: 1
+# name: test_parse_albums[spec.album]
+  dict({
+    'album_type': 'unknown',
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '91c3901ac465b9efc439e4be4270c2b6',
+        'media_type': 'artist',
+        'name': 'pornophonique',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'pornophonique',
+        'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6',
+        'version': '',
+      }),
+    ]),
+    'external_ids': list([
+    ]),
+    'favorite': True,
+    'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
+    'media_type': 'album',
+    'metadata': dict({
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8',
+          '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': '8-bit lagerfeuer',
+    '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': 'ad0f112b6dcf83de5e9cae85d07f0d35',
+        'provider_domain': 'opensubsonic',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': '8-bit lagerfeuer',
+    'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35',
+    'version': '',
+    'year': 2007,
+  })
+# ---
+# name: test_parse_albums[spec.album].1
+  dict({
+    'album_type': 'unknown',
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '91c3901ac465b9efc439e4be4270c2b6',
+        'media_type': 'artist',
+        'name': 'pornophonique',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'pornophonique',
+        'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6',
+        'version': '',
+      }),
+    ]),
+    'external_ids': list([
+    ]),
+    'favorite': True,
+    'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
+    'media_type': 'album',
+    'metadata': dict({
+      'chapters': None,
+      'copyright': None,
+      'description': 'Download the full release here (creative commons). These cripsy beats are ripe with thumping funk and techno influences, sample wizardry and daring shuffles. Composed with the help of unique sound plugins which were especially programmed to measure Comfort Fit’s needs and wishes, we think the chances aren’t bad that you’ll fall for the unique sound signature, bounce and elegance of this unusual Hip Hop production. Ltj bukem / Good looking Rec., UK: "Really love this music." Velanche / XLR8R, UK: "Awesome job he\'s done... overall production is dope." Kwesi / BBE Music, UK: "Wooooooowwwww... WHAT THE FUCK! THIS IS WHAT',
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+        dict({
+          'path': 'http://localhost:8989/play/art/0f8c3cbd6b0b22c3b5402141351ac812/album/21/thumb34.jpg',
+          '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': '8-bit lagerfeuer',
+    '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': 'ad0f112b6dcf83de5e9cae85d07f0d35',
+        'provider_domain': 'opensubsonic',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': '8-bit lagerfeuer',
+    'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35',
+    'version': '',
+    'year': 2007,
+  })
+# ---
 # name: test_parse_artists[spec-artistid3.artist]
   dict({
     'external_ids': list([
diff --git a/tests/providers/opensubsonic/fixtures/albums/spec.album.json b/tests/providers/opensubsonic/fixtures/albums/spec.album.json
new file mode 100644 (file)
index 0000000..986dbb9
--- /dev/null
@@ -0,0 +1,72 @@
+{
+    "id": "ad0f112b6dcf83de5e9cae85d07f0d35",
+    "name": "8-bit lagerfeuer",
+    "artist": "pornophonique",
+    "year": 2007,
+    "coverArt": "al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8",
+    "starred": "2023-03-22T01:51:06Z",
+    "duration": 1954,
+    "playCount": 97,
+    "genre": "Hip-Hop",
+    "created": "2023-03-10T02:19:35.784818075Z",
+    "artistId": "91c3901ac465b9efc439e4be4270c2b6",
+    "songCount": 8,
+    "played": "2023-03-28T00:45:13Z",
+    "userRating": 4,
+    "recordLabels": [
+        {
+            "name": "Sony"
+        }
+    ],
+    "musicBrainzId": "189002e7-3285-4e2e-92a3-7f6c30d407a2",
+    "genres": [
+        {
+            "name": "Hip-Hop"
+        },
+        {
+            "name": "East coast"
+        }
+    ],
+    "artists": [
+        {
+            "id": "ar-1",
+            "name": "Artist 1"
+        },
+        {
+            "id": "ar-2",
+            "name": "Artist 2"
+        }
+    ],
+    "displayArtist": "Artist 1 feat. Artist 2",
+    "releaseTypes": [
+        "Album",
+        "Remixes"
+    ],
+    "moods": [
+        "slow",
+        "cool"
+    ],
+    "sortName": "lagerfeuer (8-bit)",
+    "originalReleaseDate": {
+        "year": 2001,
+        "month": 3,
+        "day": 10
+    },
+    "releaseDate": {
+        "year": 2001,
+        "month": 3,
+        "day": 10
+    },
+    "isCompilation": false,
+    "explicitStatus": "explicit",
+    "discTitles": [
+        {
+            "disc": 0,
+            "title": "Disc 0 title"
+        },
+        {
+            "disc": 2,
+            "title": "Disc 1 title"
+        }
+    ]
+}
diff --git a/tests/providers/opensubsonic/fixtures/albums/spec.info.json b/tests/providers/opensubsonic/fixtures/albums/spec.info.json
new file mode 100644 (file)
index 0000000..b971e1b
--- /dev/null
@@ -0,0 +1,7 @@
+{
+    "notes": "Download the full release here (creative commons). These cripsy beats are ripe with thumping funk and techno influences, sample wizardry and daring shuffles. Composed with the help of unique sound plugins which were especially programmed to measure Comfort Fit’s needs and wishes, we think the chances aren’t bad that you’ll fall for the unique sound signature, bounce and elegance of this unusual Hip Hop production. Ltj bukem / Good looking Rec., UK: \"Really love this music.\" Velanche / XLR8R, UK: \"Awesome job he's done... overall production is dope.\" Kwesi / BBE Music, UK: \"Wooooooowwwww... WHAT THE FUCK! THIS IS WHAT",
+    "musicBrainzId": "6e1d48f7-717c-416e-af35-5d2454a13af2",
+    "smallImageUrl": "http://localhost:8989/play/art/0f8c3cbd6b0b22c3b5402141351ac812/album/21/thumb34.jpg",
+    "mediumImageUrl": "http://localhost:8989/play/art/41b16680dc1b3aaf5dfba24ddb6a1712/album/21/thumb64.jpg",
+    "largeImageUrl": "http://localhost:8989/play/art/e6fd8d4e0d35c4436e56991892bfb27b/album/21/thumb174.jpg"
+}
index c15f1dd48991e7326faa6b35abb79b5c6c7c379f..93fbb1ce4136bff4f580529eb408a17a2034d178 100644 (file)
@@ -1,17 +1,22 @@
 """Test we can parse Jellyfin 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 syrupy.assertion import SnapshotAssertion
 
-from music_assistant.providers.opensubsonic.parsers import parse_artist
+from music_assistant.providers.opensubsonic.parsers import parse_album, parse_artist
 
 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"))
+
+_LOGGER = logging.getLogger(__name__)
 
 
 @pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: str(val.stem))
@@ -34,3 +39,25 @@ async def test_parse_artists(example: pathlib.Path, snapshot: SnapshotAssertion)
     # 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", ALBUM_FIXTURES, ids=lambda val: str(val.stem))
+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()))
+
+    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
+    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:
+        album_info = AlbumInfo(json.loads(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