diff --git a/README.md b/README.md index 78b57f1..ad3ce6d 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,4 @@ Knuckles **only** works with music servers compatible with the REST API version 1.4.0 (Subsonic 4.2). It follows strictly the [OpenSubsonic API Spec](https://opensubsonic.netlify.app/docs/opensubsonic-api/) **without** implementing any endpoint related to video media and the legacy non-ID3 file-based system. ## Acknowledgements -Created with :heart: by [Jorge "Kutu" Dobón Blanco](dobon.dev). +Created with :heart: by [Jorge "Kutu" Dobón Blanco](https://dobon.dev). diff --git a/TODO.md b/TODO.md index 5957f3c..7a5264a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,5 @@ # TODO -- [ ] Add missing model properties. +- [ ] Add . - [ ] Make a `model` class and add the following methods to it: - [ ] `_check_api_access()` - [ ] `_resource_not_found()` @@ -11,6 +11,7 @@ - [ ] Add the `subsonic.system.check_subsonic_extension()` method. - [ ] Check and rewrite all docstrings taking care about raising exceptions. - [ ] Spin up a `MkDocs` documentation. + - [ ] Add the URL in the GitHub page. ## Implementation status The final objetive of Knuckles to be a fully compatible implementation wrapper around the [OpenSubsonic API Spec](https://opensubsonic.netlify.app/), a superset of the [Subsonic API Spec](https://subsonic.org/pages/api.jsp) that tries to improve and extend the API without breaking changes. @@ -139,38 +140,3 @@ The final objetive of Knuckles to be a fully compatible implementation wrapper a #### Media library scanning - [x] `getScanStatus` - [x] `startScan` - -### Missing model properties -#### Album -- [ ] `recordLabels` -- [ ] `musicBrainzId` -- [ ] `genres` -- [ ] `artists` -- [ ] `displayArtist` -- [ ] `releaseTypes` -- [ ] `moods` -- [ ] `sortName` -- [ ] `originalReleaseDate` -- [ ] `releaseDate` -- [ ] `isCompilation` -- [ ] `discTitles` - -#### Artist -- [ ] `musicBrainzId` -- [ ] `sortName` -- [ ] `roles` - -#### Song -- [ ] `bmp` -- [ ] `comment` -- [ ] `sortName` -- [ ] `musicBrainzId` -- [ ] `genres` -- [ ] `artists` -- [ ] `displayArtist` -- [ ] `albumArtists` -- [ ] `displayAlbumArtist` -- [ ] `contributors` -- [ ] `displayComposer` -- [ ] `moods` -- [ ] `replayGain` diff --git a/src/knuckles/models/album.py b/src/knuckles/models/album.py index 45c0c66..55a65b6 100644 --- a/src/knuckles/models/album.py +++ b/src/knuckles/models/album.py @@ -6,11 +6,35 @@ from .artist import Artist from .cover_art import CoverArt +from .genre import ItemGenre if TYPE_CHECKING: from ..subsonic import Subsonic +class RecordLabel: + def __init__(self, name: str) -> None: + self.name = name + + +class Disc: + def __init__(self, disc: int, title: str) -> None: + self.disc_number = disc + self.title = title + + +class ReleaseDate: + def __init__( + self, + year: int, + month: int, + day: int, + ) -> None: + self.year = year + self.month = month + self.day = day + + class AlbumInfo: """Representation of all the data related to an album info in Subsonic.""" @@ -94,12 +118,18 @@ def __init__( played: str | None = None, userRating: int | None = None, song: list[dict[str, Any]] | None = None, - # TODO WTF - # genres=None, - # isVideo=None, - # bpm=None, - # comment=None, - # musicBrainzId=None, + recordLabels: list[dict[str, Any]] | None = None, + musicBrainzId: str | None = None, + genres: list[dict[str, Any]] | None = None, + artists: list[dict[str, Any]] | None = None, + displayArtist: str | None = None, + releaseTypes: list[str] | None = None, + moods: list[str] | None = None, + sortName: str | None = None, + originalReleaseDate: dict[str, Any] | None = None, + releaseDate: dict[str, Any] | None = None, + isCompilation: bool | None = None, + discTitles: list[dict[str, Any]] | None = None, ) -> None: """Representation of all the data related to an album in Subsonic. @@ -170,6 +200,28 @@ def __init__( else None ) self.info: AlbumInfo | None = None + self.record_labels = ( + [RecordLabel(**record_label) for record_label in recordLabels] + if recordLabels + else None + ) + self.music_brainz_id = musicBrainzId + self.genres = [ItemGenre(**genre) for genre in genres] if genres else None + self.artists = ( + [Artist(self.__subsonic, **artist) for artist in artists] + if artists + else None + ) + self.display_artist = displayArtist + self.release_types = releaseTypes + self.moods = moods + self.sort_name = sortName + self.original_release_date = ( + ReleaseDate(**originalReleaseDate) if originalReleaseDate else None + ) + self.release_date = ReleaseDate(**releaseDate) if releaseDate else None + self.is_compilation = isCompilation + self.discs = [Disc(**disc) for disc in discTitles] if discTitles else None def generate(self) -> "Album": """Return a new album with all the data updated from the API, diff --git a/src/knuckles/models/artist.py b/src/knuckles/models/artist.py index 4808935..d9ce332 100644 --- a/src/knuckles/models/artist.py +++ b/src/knuckles/models/artist.py @@ -92,6 +92,9 @@ def __init__( userRating: int | None = None, averageRating: float | None = None, album: list[dict[str, Any]] | None = None, + musicBrainzId: str | None = None, + sortName: str | None = None, + roles: list[str] | None = None, ) -> None: """Representation of all the data related to an artist in Subsonic. @@ -135,6 +138,9 @@ def __init__( else None ) self.info: ArtistInfo | None = None + self.music_brainz_id = musicBrainzId + self.sort_name = sortName + self.roles = roles def generate(self) -> "Artist": """Return a new artist with all the data updated from the API, diff --git a/src/knuckles/models/genre.py b/src/knuckles/models/genre.py index 698c790..f35a8d3 100644 --- a/src/knuckles/models/genre.py +++ b/src/knuckles/models/genre.py @@ -6,6 +6,11 @@ from ..subsonic import Subsonic +class ItemGenre: + def __init__(self, name: str) -> None: + self.name = name + + class Genre: """Representation of all the data related to a genre in Subsonic.""" diff --git a/src/knuckles/models/song.py b/src/knuckles/models/song.py index 8244968..d1e4b65 100644 --- a/src/knuckles/models/song.py +++ b/src/knuckles/models/song.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Any, Self # Avoid circular import error -from knuckles.models.genre import Genre +from knuckles.models.genre import Genre, ItemGenre from ..exceptions import AlbumOrArtistArgumentsInSong, VideoArgumentsInSong from .album import Album @@ -16,14 +16,40 @@ from dateutil import parser +class Contributor: + def __init__( + self, + role: str, + artist: Artist, + subRole: str | None = None, + ) -> None: + self.role = role + self.subrole = subRole + self.artist = artist + + +class ReplayGain: + def __init__( + self, + trackGain: str | None = None, + albumGain: str | None = None, + trackPeak: str | None = None, + albumPeak: str | None = None, + baseGain: str | None = None, + ) -> None: + self.track_gain = trackGain + self.album_gain = albumGain + self.track_peak = trackPeak + self.album_peak = albumPeak + self.base_gain = baseGain + + class Song: """Representation of all the data related to a song in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, title: str | None = None, isDir: bool = False, @@ -55,11 +81,20 @@ def __init__( bookmarkPosition: int | None = None, originalWidth: None = None, originalHeight: None = None, - # OpenSubsonic fields played: str | None = None, - # genres=None, - # bpm=None, - # comment=None, + bpm: int | None = None, + comment: str | None = None, + sortName: str | None = None, + musicBrainzId: str | None = None, + genres: list[dict[str, Any]] | None = None, + artists: list[dict[str, Any]] | None = None, + displayArtist: str | None = None, + albumArtists: list[dict[str, Any]] | None = None, + displayAlbumArtist: str | None = None, + contributors: list[dict[str, Any]] | None = None, + displayComposer: str | None = None, + moods: list[str] | None = None, + replayGain: dict[str, Any] | None = None, ) -> None: """Representation of all the data related to song in Subsonic. @@ -180,6 +215,31 @@ def __init__( self.created = parser.parse(created) if created else None self.starred = parser.parse(starred) if starred else None self.played = parser.parse(played) if played else None + self.bpm = bpm + self.comment = comment + self.sort_name = sortName + self.music_brainz_id = musicBrainzId + self.genres = [ItemGenre(**genre) for genre in genres] if genres else None + self.artists = ( + [Artist(self.__subsonic, **artist) for artist in artists] + if artists + else None + ) + self.display_artist = displayArtist + self.album_artists = ( + [Artist(self.__subsonic, **artist) for artist in albumArtists] + if albumArtists + else None + ) + self.display_album_artist = displayAlbumArtist + self.contributors = ( + [Contributor(**contributor) for contributor in contributors] + if contributors + else None + ) + self.display_composer = displayComposer + self.moods = moods + self.replay_gain = ReplayGain(**replayGain) if replayGain else None def generate(self) -> "Song": """Return a new song with all the data updated from the API, diff --git a/tests/api/test_browsing.py b/tests/api/test_browsing.py index 949ae3f..e21875a 100644 --- a/tests/api/test_browsing.py +++ b/tests/api/test_browsing.py @@ -109,6 +109,9 @@ def test_get_artist( assert type(response.albums[0].songs) is list assert response.albums[0].songs[0].title == song["title"] assert response.cover_art.id == artist["coverArt"] + assert response.music_brainz_id == artist["musicBrainzId"] + assert response.sort_name == artist["sortName"] + assert response.roles == artist["roles"] @responses.activate @@ -142,6 +145,22 @@ def test_get_album( assert response.songs[0].id == song["id"] assert response.played == parser.parse(album["played"]) assert response.user_rating == album["userRating"] + assert type(response.record_labels) is list + assert response.record_labels[0].name == album["recordLabels"][0]["name"] + assert response.music_brainz_id == album["musicBrainzId"] + assert type(response.genres) is list + assert response.genres[0].name == album["genres"][0]["name"] + assert type(response.artists) is list + assert response.artists[0].id == album["artists"][0]["id"] + assert response.display_artist == album["displayArtist"] + assert response.release_types == album["releaseTypes"] + assert response.moods == album["moods"] + assert response.sort_name == album["sortName"] + assert response.original_release_date.year == album["originalReleaseDate"]["year"] + assert response.release_date.year == album["releaseDate"]["year"] + assert response.is_compilation == album["isCompilation"] + assert type(response.discs) is list + assert response.discs[0].disc_number == album["discTitles"][0]["disc"] @responses.activate @@ -184,6 +203,24 @@ def test_get_song( assert response.type == "music" assert response.bookmark_position is None assert response.played == parser.parse(song["played"]) + assert response.bpm == song["bpm"] + assert response.comment == song["comment"] + assert response.sort_name == song["sortName"] + assert response.music_brainz_id == song["musicBrainzId"] + assert type(response.genres) is list + assert response.genres[0].name == song["genres"][0]["name"] + assert type(response.artists) is list + assert response.artists[0].id == song["artists"][0]["id"] + assert response.display_artist == song["displayArtist"] + assert type(response.album_artists) is list + assert response.album_artists[0].name == song["albumArtists"][0]["name"] + assert response.display_album_artist == song["displayAlbumArtist"] + assert type(response.contributors) is list + assert response.contributors[0].role == song["contributors"][0]["role"] + assert response.display_composer == song["displayComposer"] + assert type(response.moods) is list + assert response.moods[0] == song["moods"][0] + assert response.replay_gain.track_gain == song["replayGain"]["trackGain"] @responses.activate diff --git a/tests/mocks/browsing.py b/tests/mocks/browsing.py index de88cd7..b4140a5 100644 --- a/tests/mocks/browsing.py +++ b/tests/mocks/browsing.py @@ -44,6 +44,9 @@ def artist(base_url: str, album: dict[str, Any]) -> dict[str, Any]: "artistImageUrl": f"{base_url}/artist.png", "starred": "2017-04-11T10:42:50.842Z", "album": [album], + "musicBrainzId": "189002e7-3285-4e2e-92a3-7f6c30d407a2", + "sortName": "Mello (2)", + "roles": ["artist", "albumartist", "composer"], } @@ -99,6 +102,24 @@ def album(song: dict[str, Any]) -> dict[str, Any]: "song": [song], "played": "2023-03-26T22:27:46Z", "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, + "discTitles": [ + {"disc": 0, "title": "Disc 0 title"}, + {"disc": 2, "title": "Disc 1 title"}, + ], } @@ -139,6 +160,40 @@ def song(genre: dict[str, Any]) -> dict[str, Any]: "isVideo": False, "userRating": 5, "averageRating": 4.8, + "bpm": 134, + "comment": "This is a song comment", + "sortName": "Polar expedition", + "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", + "albumArtists": [ + {"id": "ar-6", "name": "Artist 6"}, + {"id": "ar-7", "name": "Artist 7"}, + ], + "displayAlbumArtist": "Artist 6 & Artist 7", + "contributors": [ + {"role": "composer", "artist": {"id": "ar-3", "name": "Artist 3"}}, + {"role": "composer", "artist": {"id": "ar-4", "name": "Artist 4"}}, + {"role": "lyricist", "artist": {"id": "ar-5", "name": "Artist 5"}}, + { + "role": "performer", + "subRole": "Bass", + "artist": {"id": "ar-5", "name": "Artist 5"}, + }, + ], + "displayComposer": "Artist 3, Artist 4", + "moods": ["slow", "cool"], + "replayGain": { + "trackGain": 0.1, + "albumGain": 1.1, + "trackPeak": 9.2, + "albumPeak": 9, + "baseGain": 0, + }, }