diff --git a/TODO.md b/TODO.md index 7a5264a..8f630e3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,8 @@ # TODO -- [ ] Add . -- [ ] Make a `model` class and add the following methods to it: - - [ ] `_check_api_access()` - - [ ] `_resource_not_found()` -- [ ] Should `CoverArt` be removed? -- [ ] Determine if video and non-ID3 endpoints will be supported. - - [ ] If not the Video and NonID3 checks in the `Song` model should be removed. - [ ] Implement missing endpoints. -- [ ] Improve error handling: - [ ] Add the `subsonic.system.check_subsonic_extension()` method. -- [ ] Check and rewrite all docstrings taking care about raising exceptions. +- [ ] Improve error handling: + - [ ] Check and rewrite all docstrings taking care about raising exceptions. - [ ] Spin up a `MkDocs` documentation. - [ ] Add the URL in the GitHub page. diff --git a/src/knuckles/chat.py b/src/knuckles/chat.py index fb4c4aa..5e8c0ff 100644 --- a/src/knuckles/chat.py +++ b/src/knuckles/chat.py @@ -41,6 +41,6 @@ def get_chat_messages(self) -> list[ChatMessage]: "chatMessages" ]["chatMessage"] - messages = [ChatMessage(**message) for message in response] + messages = [ChatMessage(self.subsonic, **message) for message in response] return messages diff --git a/src/knuckles/exceptions.py b/src/knuckles/exceptions.py index 7ee4abd..a975879 100644 --- a/src/knuckles/exceptions.py +++ b/src/knuckles/exceptions.py @@ -1,7 +1,7 @@ from typing import Type -class NoApiAccess(Exception): +class MissingRequiredProperty(Exception): pass @@ -9,16 +9,15 @@ class InvalidRatingNumber(ValueError): pass -class VideoArgumentsInSong(ValueError): - pass - - class ResourceNotFound(Exception): - pass - - -class AlbumOrArtistArgumentsInSong(ValueError): - pass + def __init__( + self, + message: str = ( + "Unable to generate the model as it does not exist in the server" + ), + *args: str + ) -> None: + super().__init__(message, *args) class ShareInvalidSongList(ValueError): diff --git a/src/knuckles/media_library_scanning.py b/src/knuckles/media_library_scanning.py index c3837c3..50e273a 100644 --- a/src/knuckles/media_library_scanning.py +++ b/src/knuckles/media_library_scanning.py @@ -1,6 +1,11 @@ +from typing import TYPE_CHECKING + from .api import Api from .models.scan_status import ScanStatus +if TYPE_CHECKING: + from .subsonic import Subsonic + class MediaLibraryScanning: """Class that contains all the methods needed to interact @@ -8,9 +13,12 @@ class MediaLibraryScanning: """ - def __init__(self, api: Api) -> None: + def __init__(self, api: Api, subsonic: "Subsonic") -> None: self.api = api + # Only to pass it to the models + self.subsonic = subsonic + def get_scan_status(self) -> ScanStatus: """Calls to the "getScanStatus" endpoint of the API. @@ -20,7 +28,7 @@ def get_scan_status(self) -> ScanStatus: response = self.api.json_request("getScanStatus")["scanStatus"] - return ScanStatus(**response) + return ScanStatus(self.subsonic, **response) def start_scan(self) -> ScanStatus: """Calls to the "scanStatus" endpoint of the API. @@ -31,4 +39,4 @@ def start_scan(self) -> ScanStatus: response = self.api.json_request("startScan")["scanStatus"] - return ScanStatus(**response) + return ScanStatus(self.subsonic, **response) diff --git a/src/knuckles/models/album.py b/src/knuckles/models/album.py index 55a65b6..19781bc 100644 --- a/src/knuckles/models/album.py +++ b/src/knuckles/models/album.py @@ -7,43 +7,49 @@ from .artist import Artist from .cover_art import CoverArt from .genre import ItemGenre +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic -class RecordLabel: - def __init__(self, name: str) -> None: +class RecordLabel(Model): + def __init__(self, subsonic: "Subsonic", name: str) -> None: + super().__init__(subsonic) + self.name = name -class Disc: - def __init__(self, disc: int, title: str) -> None: +class Disc(Model): + def __init__(self, subsonic: "Subsonic", disc: int, title: str) -> None: + super().__init__(subsonic) + self.disc_number = disc self.title = title -class ReleaseDate: +class ReleaseDate(Model): def __init__( self, + subsonic: "Subsonic", year: int, month: int, day: int, ) -> None: + super().__init__(subsonic) + self.year = year self.month = month self.day = day -class AlbumInfo: +class AlbumInfo(Model): """Representation of all the data related to an album info in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", album_id: str, - # Subsonic fields notes: str, musicBrainzId: str | None, lastFmUrl: str | None, @@ -68,7 +74,8 @@ def __init__( :type largeImageUrl: str """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.album_id = album_id self.notes = notes self.music_brainz_id = musicBrainzId @@ -88,17 +95,15 @@ def generate(self) -> "AlbumInfo": :rtype: AlbumInfo """ - return self.__subsonic.browsing.get_album_info(self.album_id) + return self._subsonic.browsing.get_album_info(self.album_id) -class Album: +class Album(Model): """Representation of all the data related to an album in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, parent: str | None = None, album: str | None = None, @@ -176,15 +181,16 @@ def __init__( :type song: list[dict[str, Any]] """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.parent = parent self.album = album self.name = name self.is_dir = isDir self.title = title - self.artist = Artist(self.__subsonic, artistId, artist) if artistId else None - self.cover_art = CoverArt(coverArt) if coverArt else None + self.artist = Artist(self._subsonic, artistId, artist) if artistId else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None self.song_count = songCount self.duration = duration self.play_count = playCount @@ -195,20 +201,25 @@ def __init__( self.played = parser.parse(played) if played else None self.user_rating = userRating self.songs = ( - [song_model_module.Song(self.__subsonic, **song_data) for song_data in song] + [song_model_module.Song(self._subsonic, **song_data) for song_data in song] if song else None ) self.info: AlbumInfo | None = None self.record_labels = ( - [RecordLabel(**record_label) for record_label in recordLabels] + [ + RecordLabel(self._subsonic, **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.genres = ( + [ItemGenre(self._subsonic, **genre) for genre in genres] if genres else None + ) self.artists = ( - [Artist(self.__subsonic, **artist) for artist in artists] + [Artist(self._subsonic, **artist) for artist in artists] if artists else None ) @@ -217,11 +228,19 @@ def __init__( self.moods = moods self.sort_name = sortName self.original_release_date = ( - ReleaseDate(**originalReleaseDate) if originalReleaseDate else None + ReleaseDate(self._subsonic, **originalReleaseDate) + if originalReleaseDate + else None + ) + self.release_date = ( + ReleaseDate(self._subsonic, **releaseDate) if releaseDate 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 + self.discs = ( + [Disc(self._subsonic, **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, @@ -234,7 +253,7 @@ def generate(self) -> "Album": :rtype: Album """ - new_album = self.__subsonic.browsing.get_album(self.id) + new_album = self._subsonic.browsing.get_album(self.id) new_album.get_album_info() return new_album @@ -247,6 +266,6 @@ def get_album_info(self) -> AlbumInfo: :rtype: AlbumInfo """ - self.info = self.__subsonic.browsing.get_album_info(self.id) + self.info = self._subsonic.browsing.get_album_info(self.id) return self.info diff --git a/src/knuckles/models/artist.py b/src/knuckles/models/artist.py index d9ce332..0a40981 100644 --- a/src/knuckles/models/artist.py +++ b/src/knuckles/models/artist.py @@ -4,6 +4,7 @@ import knuckles.models.album as album_model_module from .cover_art import CoverArt +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic @@ -11,15 +12,13 @@ from dateutil import parser -class ArtistInfo: +class ArtistInfo(Model): """Representation of all the data related to an artist info in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", artist_id: str, - # Subsonic fields biography: str, musicBrainzId: str | None, lastFmUrl: str | None, @@ -47,7 +46,8 @@ def __init__( :type similarArtist: list[str, Any] """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.artist_id = artist_id self.biography = biography self.music_brainz_id = musicBrainzId @@ -56,7 +56,7 @@ def __init__( self.medium_image_url = mediumImageUrl self.large_image_url = largeImageUrl self.similar_artists = ( - [Artist(self.__subsonic, **artist) for artist in similarArtist] + [Artist(self._subsonic, **artist) for artist in similarArtist] if similarArtist else None ) @@ -72,17 +72,15 @@ def generate(self) -> "ArtistInfo": :rtype: ArtistInfo """ - return self.__subsonic.browsing.get_artist_info(self.artist_id) + return self._subsonic.browsing.get_artist_info(self.artist_id) -class Artist: +class Artist(Model): """Representation of all the data related to an artist in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, name: str | None = None, coverArt: str | None = None, @@ -120,10 +118,11 @@ def __init__( :type album: list[dict[str, Any]] """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.name = name - self.cover_art = CoverArt(coverArt) if coverArt else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None self.artist_image_url = artistImageUrl self.album_count = albumCount self.starred = parser.parse(starred) if starred else None @@ -131,7 +130,7 @@ def __init__( self.average_rating = averageRating self.albums = ( [ - album_model_module.Album(self.__subsonic, **album_data) + album_model_module.Album(self._subsonic, **album_data) for album_data in album ] if album @@ -153,7 +152,7 @@ def generate(self) -> "Artist": :rtype: Artist """ - new_artist = self.__subsonic.browsing.get_artist(self.id) + new_artist = self._subsonic.browsing.get_artist(self.id) new_artist.get_artist_info() return new_artist @@ -166,6 +165,6 @@ def get_artist_info(self) -> ArtistInfo: :rtype: AlbumInfo """ - self.info = self.__subsonic.browsing.get_artist_info(self.id) + self.info = self._subsonic.browsing.get_artist_info(self.id) return self.info diff --git a/src/knuckles/models/bookmark.py b/src/knuckles/models/bookmark.py index 804ea6f..01f442d 100644 --- a/src/knuckles/models/bookmark.py +++ b/src/knuckles/models/bookmark.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Any, Self from ..exceptions import ResourceNotFound +from .model import Model from .song import Song from .user import User @@ -10,14 +11,12 @@ from dateutil import parser -class Bookmark: +class Bookmark(Model): """Representation of all the data related to a bookmark in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields entry: dict[str, Any], position: int, username: str | None = None, @@ -25,11 +24,13 @@ def __init__( changed: str | None = None, comment: str | None = None, ) -> None: - self.__subsonic = subsonic - self.song = Song(self.__subsonic, **entry) + + super().__init__(subsonic) + + self.song = Song(self._subsonic, **entry) self.position = position self.user = ( - User(subsonic=self.__subsonic, username=username) if username else None + User(subsonic=self._subsonic, username=username) if username else None ) self.comment = comment self.created = parser.parse(created) if created else None @@ -46,12 +47,10 @@ def generate(self) -> "Bookmark": :rtype: Bookmark """ - get_bookmark = self.__subsonic.bookmarks.get_bookmark(self.song.id) + get_bookmark = self._subsonic.bookmarks.get_bookmark(self.song.id) if get_bookmark is None: - raise ResourceNotFound( - "Unable to generate episode as it does not exist in the server" - ) + raise ResourceNotFound() return get_bookmark @@ -62,7 +61,7 @@ def create(self) -> Self: :rtype: Self """ - self.__subsonic.bookmarks.create_bookmark( + self._subsonic.bookmarks.create_bookmark( self.song.id, self.position, self.comment ) @@ -76,7 +75,7 @@ def update(self) -> Self: :rtype: Self """ - self.__subsonic.bookmarks.update_bookmark( + self._subsonic.bookmarks.update_bookmark( self.song.id, self.position, self.comment ) @@ -89,6 +88,6 @@ def delete(self) -> Self: :rtype: Self """ - self.__subsonic.bookmarks.delete_bookmark(self.song.id) + self._subsonic.bookmarks.delete_bookmark(self.song.id) return self diff --git a/src/knuckles/models/chat_message.py b/src/knuckles/models/chat_message.py index bf5cbdc..7542595 100644 --- a/src/knuckles/models/chat_message.py +++ b/src/knuckles/models/chat_message.py @@ -1,10 +1,18 @@ from datetime import datetime +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from ..subsonic import Subsonic -class ChatMessage: +from knuckles.models.model import Model + + +class ChatMessage(Model): """Representation of all the data related to a chat message in Subsonic.""" - def __init__(self, username: str, time: int, message: str) -> None: + def __init__( + self, subsonic: "Subsonic", username: str, time: int, message: str + ) -> None: """Representation of all the data related to a chat message in Subsonic. :param username: The username of the creator of the message @@ -15,8 +23,10 @@ def __init__(self, username: str, time: int, message: str) -> None: :type message: str """ + super().__init__(subsonic) + self.username: str = username + self.message: str = message # Divide by 1000 as the Subsonic API return in milliseconds instead of seconds self.time: datetime = datetime.fromtimestamp(time / 1000) - self.message: str = message diff --git a/src/knuckles/models/cover_art.py b/src/knuckles/models/cover_art.py index 94f611f..69a81d8 100644 --- a/src/knuckles/models/cover_art.py +++ b/src/knuckles/models/cover_art.py @@ -1,11 +1,21 @@ -class CoverArt: +from typing import TYPE_CHECKING + +from .model import Model + +if TYPE_CHECKING: + from ..subsonic import Subsonic + + +class CoverArt(Model): """Representation of all the data related to a cover art in Subsonic.""" - def __init__(self, id: str) -> None: + def __init__(self, subsonic: "Subsonic", id: str) -> None: """Representation of all the data related to a cover art in Subsonic. :param id: The ID of the cover art. :type id: str """ + super().__init__(subsonic) + self.id: str = id diff --git a/src/knuckles/models/genre.py b/src/knuckles/models/genre.py index f35a8d3..b623301 100644 --- a/src/knuckles/models/genre.py +++ b/src/knuckles/models/genre.py @@ -1,17 +1,21 @@ from typing import TYPE_CHECKING from ..exceptions import ResourceNotFound +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic -class ItemGenre: - def __init__(self, name: str) -> None: +class ItemGenre(Model): + def __init__(self, subsonic: "Subsonic", name: str) -> None: + + super().__init__(subsonic) + self.name = name -class Genre: +class Genre(Model): """Representation of all the data related to a genre in Subsonic.""" def __init__( @@ -33,7 +37,8 @@ def __init__( :type albumCount: int | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.value = value self.song_count = songCount self.album_count = albumCount @@ -48,7 +53,7 @@ def generate(self) -> "Genre": :return: A new genre object with all the data updated. :rtype: Genre """ - get_genre = self.__subsonic.browsing.get_genre(self.value) + get_genre = self._subsonic.browsing.get_genre(self.value) if get_genre is None: raise ResourceNotFound( diff --git a/src/knuckles/models/internet_radio_station.py b/src/knuckles/models/internet_radio_station.py index e1b1d0c..84d7621 100644 --- a/src/knuckles/models/internet_radio_station.py +++ b/src/knuckles/models/internet_radio_station.py @@ -1,12 +1,13 @@ from typing import TYPE_CHECKING, Self from ..exceptions import ResourceNotFound +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic -class InternetRadioStation: +class InternetRadioStation(Model): """Representation of all the data related to an internet radio station in Subsonic. """ @@ -34,7 +35,8 @@ def __init__( :type homepageUrl: str """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.name = name self.stream_url = streamUrl @@ -51,7 +53,7 @@ def generate(self) -> "InternetRadioStation | None": :rtype: InternetRadioStation """ - get_station = self.__subsonic.internet_radio.get_internet_radio_station(self.id) + get_station = self._subsonic.internet_radio.get_internet_radio_station(self.id) if get_station is None: raise ResourceNotFound( @@ -70,7 +72,7 @@ def create(self) -> Self: :rtype: Self """ - self.__subsonic.internet_radio.create_internet_radio_station( + self._subsonic.internet_radio.create_internet_radio_station( self.stream_url, self.name, self.homepage_url ) @@ -83,7 +85,7 @@ def update(self) -> Self: :rtype: Self """ - self.__subsonic.internet_radio.update_internet_radio_station( + self._subsonic.internet_radio.update_internet_radio_station( self.id, self.stream_url, self.name, self.homepage_url ) @@ -96,6 +98,6 @@ def delete(self) -> Self: :rtype: Self """ - self.__subsonic.internet_radio.delete_internet_radio_station(self.id) + self._subsonic.internet_radio.delete_internet_radio_station(self.id) return self diff --git a/src/knuckles/models/jukebox.py b/src/knuckles/models/jukebox.py index b1baa1e..e0441c5 100644 --- a/src/knuckles/models/jukebox.py +++ b/src/knuckles/models/jukebox.py @@ -1,19 +1,18 @@ from typing import TYPE_CHECKING, Any, Self +from .model import Model from .song import Song if TYPE_CHECKING: from ..subsonic import Subsonic -class Jukebox: +class Jukebox(Model): """Representation of all the data related to the jukebox in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields currentIndex: int, playing: bool, gain: float, @@ -36,7 +35,8 @@ def __init__( :type entry: list[dict[str, Any]] | None, optional """ - self.__subsonic: "Subsonic" = subsonic + super().__init__(subsonic) + self.current_index: int = currentIndex self.playing: bool = playing self.gain: float = gain @@ -49,7 +49,7 @@ def __init__( self.playlist = [] for song in entry: - self.playlist.append(Song(subsonic=self.__subsonic, **song)) + self.playlist.append(Song(subsonic=self._subsonic, **song)) def generate(self) -> "Jukebox": """Return a new jukebox with all the data updated from the API, @@ -62,7 +62,7 @@ def generate(self) -> "Jukebox": :rtype: Jukebox """ - return self.__subsonic.jukebox.get() + return self._subsonic.jukebox.get() def start(self) -> Self: """Calls the "jukeboxControl" endpoint of the API with the action "start". @@ -71,7 +71,7 @@ def start(self) -> Self: :rtype: Self """ - self.__subsonic.jukebox.start() + self._subsonic.jukebox.start() return self @@ -82,7 +82,7 @@ def stop(self) -> Self: :rtype: Self """ - self.__subsonic.jukebox.stop() + self._subsonic.jukebox.stop() return self @@ -97,7 +97,7 @@ def skip(self, index: int, offset: float = 0) -> Self: :rtype: Self """ - self.__subsonic.jukebox.skip(index, offset) + self._subsonic.jukebox.skip(index, offset) return self @@ -108,11 +108,11 @@ def shuffle(self) -> Self: :rtype: Self """ - self.__subsonic.jukebox.shuffle() + self._subsonic.jukebox.shuffle() # The shuffle is server side so a call to the API is necessary # to get the new order of the playlist - self.playlist = self.__subsonic.jukebox.get().playlist + self.playlist = self._subsonic.jukebox.get().playlist return self @@ -125,7 +125,7 @@ def set_gain(self, gain: float) -> Self: :rtype: Self """ - self.__subsonic.jukebox.set_gain(gain) + self._subsonic.jukebox.set_gain(gain) self.gain = gain return self @@ -137,7 +137,7 @@ def clear(self) -> Self: :rtype: Self """ - self.__subsonic.jukebox.clear() + self._subsonic.jukebox.clear() self.playlist = [] return self @@ -152,9 +152,9 @@ def set(self, id: str) -> Self: :rtype: Self """ - song_to_set: Song = Song(self.__subsonic, id) + song_to_set: Song = Song(self._subsonic, id) - self.__subsonic.jukebox.set(song_to_set.id) + self._subsonic.jukebox.set(song_to_set.id) self.playlist = [song_to_set] return self @@ -170,9 +170,9 @@ def add(self, id: str) -> Self: :rtype: Self """ - song_to_add: Song = Song(self.__subsonic, id) + song_to_add: Song = Song(self._subsonic, id) - self.__subsonic.jukebox.add(song_to_add.id) + self._subsonic.jukebox.add(song_to_add.id) if self.playlist is not None: self.playlist.append(song_to_add) @@ -192,7 +192,7 @@ def remove(self, index: int) -> Self: :rtype: Self """ - self.__subsonic.jukebox.remove(index) + self._subsonic.jukebox.remove(index) if self.playlist is not None: del self.playlist[index] diff --git a/src/knuckles/models/model.py b/src/knuckles/models/model.py new file mode 100644 index 0000000..0543048 --- /dev/null +++ b/src/knuckles/models/model.py @@ -0,0 +1,9 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..subsonic import Subsonic + + +class Model: + def __init__(self, subsonic: "Subsonic") -> None: + self._subsonic = subsonic diff --git a/src/knuckles/models/music_folder.py b/src/knuckles/models/music_folder.py index 9fbcf7c..e7b3967 100644 --- a/src/knuckles/models/music_folder.py +++ b/src/knuckles/models/music_folder.py @@ -1,10 +1,12 @@ from typing import TYPE_CHECKING +from .model import Model + if TYPE_CHECKING: from ..subsonic import Subsonic -class MusicFolder: +class MusicFolder(Model): """Representation of all the data related to a music folder in Subsonic.""" def __init__(self, subsonic: "Subsonic", id: str, name: str | None = None) -> None: @@ -18,7 +20,8 @@ def __init__(self, subsonic: "Subsonic", id: str, name: str | None = None) -> No :type name: str | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.name = name @@ -33,7 +36,7 @@ def generate(self) -> "MusicFolder": :rtype: MusicFolder """ - music_folders = self.__subsonic.browsing.get_music_folders() + music_folders = self._subsonic.browsing.get_music_folders() # Get the first element with the same ID music_folder = next( diff --git a/src/knuckles/models/play_queue.py b/src/knuckles/models/play_queue.py index 8cb1520..3df42c5 100644 --- a/src/knuckles/models/play_queue.py +++ b/src/knuckles/models/play_queue.py @@ -2,6 +2,7 @@ from dateutil import parser +from .model import Model from .song import Song from .user import User @@ -9,7 +10,7 @@ from ..subsonic import Subsonic -class PlayQueue: +class PlayQueue(Model): """Representation of all the data related to a play queue in Subsonic.""" def __init__( @@ -22,15 +23,15 @@ def __init__( changed: str | None = None, changedBy: str | None = None, ) -> None: - self.__subsonic = subsonic - self.current = Song(self.__subsonic, current) if current else None + + super().__init__(subsonic) + + self.current = Song(self._subsonic, current) if current else None self.position = position - self.user = User(self.__subsonic, username) if username else None + self.user = User(self._subsonic, username) if username else None self.changed = parser.parse(changed) if changed else None self.changed_by = changedBy - self.songs = ( - [Song(self.__subsonic, **song) for song in entry] if entry else None - ) + self.songs = [Song(self._subsonic, **song) for song in entry] if entry else None def generate(self) -> "PlayQueue": """Return a new play queue with all the data updated from the API, @@ -43,7 +44,7 @@ def generate(self) -> "PlayQueue": :rtype: PlayQueue """ - get_play_queue = self.__subsonic.bookmarks.get_play_queue() + get_play_queue = self._subsonic.bookmarks.get_play_queue() return get_play_queue @@ -58,7 +59,7 @@ def save(self) -> Self: song_ids: list[str] = [song.id for song in self.songs] if self.songs else [] - self.__subsonic.bookmarks.save_play_queue( + self._subsonic.bookmarks.save_play_queue( song_ids, self.current.id if self.current else None, self.position ) diff --git a/src/knuckles/models/playlist.py b/src/knuckles/models/playlist.py index 05fa1bb..0d4add5 100644 --- a/src/knuckles/models/playlist.py +++ b/src/knuckles/models/playlist.py @@ -2,6 +2,7 @@ from dateutil import parser +from .model import Model from .song import CoverArt, Song from .user import User @@ -9,14 +10,12 @@ from ..subsonic import Subsonic -class Playlist: +class Playlist(Model): """Representation of all the data related to a playlist in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, name: str | None = None, songCount: int | None = None, @@ -61,7 +60,8 @@ def __init__( :type entry: list[dict[str, Any]] | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.name = name self.song_count = songCount @@ -69,17 +69,15 @@ def __init__( self.created = parser.parse(created) if created else None self.changed = parser.parse(changed) if changed else None self.comment = comment - self.owner = User(self.__subsonic, owner) if owner else None + self.owner = User(self._subsonic, owner) if owner else None self.public = public - self.cover_art = CoverArt(coverArt) if coverArt else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None self.allowed_users = ( - [User(self.__subsonic, username) for username in allowedUser] + [User(self._subsonic, username) for username in allowedUser] if allowedUser else None ) - self.songs = ( - [Song(self.__subsonic, **song) for song in entry] if entry else None - ) + self.songs = [Song(self._subsonic, **song) for song in entry] if entry else None def generate(self) -> "Playlist": """Return a new playlist with all the data updated from the API, @@ -89,7 +87,7 @@ def generate(self) -> "Playlist": :rtype: Playlist """ - return self.__subsonic.playlists.get_playlist(self.id) + return self._subsonic.playlists.get_playlist(self.id) def create(self) -> "Playlist": """Calls the "createPlaylist" endpoint of the API. @@ -103,7 +101,7 @@ def create(self) -> "Playlist": # Create a list of Song IDs if songs is not None songs_ids = [song.id for song in self.songs] if self.songs else None - new_playlist = self.__subsonic.playlists.create_playlist( + new_playlist = self._subsonic.playlists.create_playlist( # Ignore the None type error as the server # should return an Error Code 10 in response self.name, # type: ignore[arg-type] @@ -126,7 +124,7 @@ def update(self) -> Self: :return: The object itself to allow method chaining. :rtype: Self """ - self.__subsonic.playlists.update_playlist( + self._subsonic.playlists.update_playlist( self.id, self.name, self.comment, self.public ) @@ -140,7 +138,7 @@ def delete(self) -> Self: :return: The object itself to allow method chaining. :rtype: Self """ - self.__subsonic.playlists.delete_playlist(self.id) + self._subsonic.playlists.delete_playlist(self.id) return self @@ -154,13 +152,13 @@ def add_songs(self, song_ids: list[str]) -> Self: :rtype: Self """ - self.__subsonic.playlists.update_playlist(self.id, song_ids_to_add=song_ids) + self._subsonic.playlists.update_playlist(self.id, song_ids_to_add=song_ids) if not self.songs: self.songs = [] for id_ in song_ids: - self.songs.append(Song(self.__subsonic, id_)) + self.songs.append(Song(self._subsonic, id_)) if not self.song_count: self.song_count = 0 @@ -179,7 +177,7 @@ def remove_songs(self, songs_indexes: list[int]) -> Self: :rtype: Self """ - self.__subsonic.playlists.update_playlist( + self._subsonic.playlists.update_playlist( self.id, song_indexes_to_remove=songs_indexes ) diff --git a/src/knuckles/models/podcast.py b/src/knuckles/models/podcast.py index 9db3bc0..970fba3 100644 --- a/src/knuckles/models/podcast.py +++ b/src/knuckles/models/podcast.py @@ -2,6 +2,7 @@ from ..exceptions import ResourceNotFound from .cover_art import CoverArt +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic @@ -9,14 +10,12 @@ from dateutil import parser -class Episode: +class Episode(Model): """Representation of all the data related to a podcast episode in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, streamId: str | None = None, channelId: str | None = None, @@ -79,10 +78,11 @@ def __init__( :type path: str | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.stream_id = streamId - self.channel = Channel(self.__subsonic, channelId) if channelId else None + self.channel = Channel(self._subsonic, channelId) if channelId else None self.title = title self.description = description self.publish_date = parser.parse(publishDate) if publishDate else None @@ -91,7 +91,7 @@ def __init__( self.is_dir = isDir self.year = year self.genre = genre - self.cover_art = CoverArt(coverArt) if coverArt else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None self.size = size self.content_type = contentType self.suffix = suffix @@ -110,7 +110,7 @@ def generate(self) -> "Episode": :rtype: Episode """ - get_episode = self.__subsonic.podcast.get_episode(self.id) + get_episode = self._subsonic.podcast.get_episode(self.id) if get_episode is None: raise ResourceNotFound( @@ -126,7 +126,7 @@ def download(self) -> Self: :rtype: Self """ - self.__subsonic.podcast.download_podcast_episode(self.id) + self._subsonic.podcast.download_podcast_episode(self.id) return self @@ -137,19 +137,17 @@ def delete(self) -> Self: :rtype: Self """ - self.__subsonic.podcast.delete_podcast_episode(self.id) + self._subsonic.podcast.delete_podcast_episode(self.id) return self -class Channel: +class Channel(Model): """Representation of all the data related to a podcast channel in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, url: str | None = None, title: str | None = None, @@ -182,16 +180,17 @@ def __init__( :type episode: list[dict[str, Any]] | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.url = url self.title = title self.description = description - self.cover_art = CoverArt(coverArt) if coverArt else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None self.original_image_url = originalImageUrl self.status = status self.episodes = ( - [Episode(self.__subsonic, **episode_data) for episode_data in episode] + [Episode(self._subsonic, **episode_data) for episode_data in episode] if episode else None ) @@ -207,7 +206,7 @@ def generate(self) -> "Channel": :rtype: Channel """ - return self.__subsonic.podcast.get_podcast(self.id) + return self._subsonic.podcast.get_podcast(self.id) def create(self) -> Self: """Calls the "createPodcastChannel" endpoint of the API. @@ -218,7 +217,7 @@ def create(self) -> Self: # Ignore the None type error as the server # should return an Error Code 10 in response - self.__subsonic.podcast.create_podcast_channel( + self._subsonic.podcast.create_podcast_channel( self.url # type: ignore[arg-type] ) @@ -231,6 +230,6 @@ def delete(self) -> Self: :rtype: Self """ - self.__subsonic.podcast.delete_podcast_channel(self.id) + self._subsonic.podcast.delete_podcast_channel(self.id) return self diff --git a/src/knuckles/models/scan_status.py b/src/knuckles/models/scan_status.py index 574549e..545bf6d 100644 --- a/src/knuckles/models/scan_status.py +++ b/src/knuckles/models/scan_status.py @@ -1,9 +1,17 @@ -class ScanStatus: +from typing import TYPE_CHECKING + +from .model import Model + +if TYPE_CHECKING: + from ..subsonic import Subsonic + + +class ScanStatus(Model): """Representation of all the data related to the status of a library scan in Subsonic. """ - def __init__(self, scanning: bool, count: int) -> None: + def __init__(self, subsonic: "Subsonic", scanning: bool, count: int) -> None: """Representation of all the data related to the status of a library scan in Subsonic. @@ -13,5 +21,7 @@ def __init__(self, scanning: bool, count: int) -> None: :type count: int """ + super().__init__(subsonic) + self.scanning: bool = scanning self.count: int = count diff --git a/src/knuckles/models/share.py b/src/knuckles/models/share.py index 210a01d..c94230a 100644 --- a/src/knuckles/models/share.py +++ b/src/knuckles/models/share.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Any, Self from ..exceptions import ResourceNotFound, ShareInvalidSongList +from .model import Model from .song import Song from .user import User @@ -10,14 +11,12 @@ from dateutil import parser -class Share: +class Share(Model): """Representation of all the data related to a share in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields id: str, url: str | None = None, description: str | None = None, @@ -54,18 +53,17 @@ def __init__( :type entry: list[dict[str, Any]] | None, optional """ - self.__subsonic = subsonic + super().__init__(subsonic) + self.id = id self.url = url self.description = description - self.user = User(self.__subsonic, username) if username else None + self.user = User(self._subsonic, username) if username else None self.created = parser.parse(created) if created else None self.expires = parser.parse(expires) if expires else None self.last_visited = parser.parse(lastVisited) if lastVisited else None self.visit_count = visitCount - self.songs = ( - [Song(self.__subsonic, **song) for song in entry] if entry else None - ) + self.songs = [Song(self._subsonic, **song) for song in entry] if entry else None def generate(self) -> "Share | None": """Return a new share with all the data updated from the API, @@ -78,12 +76,10 @@ def generate(self) -> "Share | None": :rtype: Share """ - get_share = self.__subsonic.sharing.get_share(self.id) + get_share = self._subsonic.sharing.get_share(self.id) if get_share is None: - raise ResourceNotFound( - "Unable to generate share as it does not exist in the server" - ) + raise ResourceNotFound return get_share @@ -109,7 +105,7 @@ def create(self) -> "Share": songs_ids = [song.id for song in self.songs] - new_share = self.__subsonic.sharing.create_share( + new_share = self._subsonic.sharing.create_share( songs_ids, self.description, self.expires ) @@ -125,7 +121,7 @@ def update(self) -> Self: :rtype: Self """ - self.__subsonic.sharing.update_share(self.id, self.description, self.expires) + self._subsonic.sharing.update_share(self.id, self.description, self.expires) return self @@ -138,6 +134,6 @@ def delete(self) -> Self: :rtype: Self """ - self.__subsonic.sharing.delete_share(self.id) + self._subsonic.sharing.delete_share(self.id) return self diff --git a/src/knuckles/models/song.py b/src/knuckles/models/song.py index d1e4b65..166a4d2 100644 --- a/src/knuckles/models/song.py +++ b/src/knuckles/models/song.py @@ -3,10 +3,10 @@ # Avoid circular import error from knuckles.models.genre import Genre, ItemGenre -from ..exceptions import AlbumOrArtistArgumentsInSong, VideoArgumentsInSong from .album import Album from .artist import Artist from .cover_art import CoverArt +from .model import Model if TYPE_CHECKING: from ..subsonic import Subsonic @@ -16,27 +16,35 @@ from dateutil import parser -class Contributor: +class Contributor(Model): def __init__( self, + subsonic: "Subsonic", role: str, artist: Artist, subRole: str | None = None, ) -> None: + + super().__init__(subsonic) + self.role = role self.subrole = subRole self.artist = artist -class ReplayGain: +class ReplayGain(Model): def __init__( self, + subsonic: "Subsonic", trackGain: str | None = None, albumGain: str | None = None, trackPeak: str | None = None, albumPeak: str | None = None, baseGain: str | None = None, ) -> None: + + super().__init__(subsonic) + self.track_gain = trackGain self.album_gain = albumGain self.track_peak = trackPeak @@ -44,7 +52,7 @@ def __init__( self.base_gain = baseGain -class Song: +class Song(Model): """Representation of all the data related to a song in Subsonic.""" def __init__( @@ -175,26 +183,14 @@ def __init__( for albums or artists are passed in. """ - if isVideo or originalWidth is not None or originalHeight is not None: - raise VideoArgumentsInSong( - ( - "A song shouldn't contain values valid for videos." - + "Did you mean: Video()?" - ) - ) - - if isDir: - raise AlbumOrArtistArgumentsInSong( - "'isDir' shouldn't be True. Did you mean: Album() or Artist()?" - ) + super().__init__(subsonic) - self.__subsonic = subsonic self.id: str = id self.title: str | None = title self.parent: str | None = parent self.track: int | None = track self.year: int | None = year - self.genre = Genre(self.__subsonic, genre) if genre else None + self.genre = Genre(self._subsonic, genre) if genre else None self.size: int | None = size self.content_type: str | None = contentType self.suffix: str | None = suffix @@ -209,9 +205,9 @@ def __init__( self.disc_number: int | None = discNumber self.type: str | None = type self.bookmark_position: int | None = bookmarkPosition - self.album = Album(self.__subsonic, albumId, name=album) if albumId else None - self.artist = Artist(self.__subsonic, artistId, artist) if artistId else None - self.cover_art = CoverArt(coverArt) if coverArt else None + self.album = Album(self._subsonic, albumId, name=album) if albumId else None + self.artist = Artist(self._subsonic, artistId, artist) if artistId else None + self.cover_art = CoverArt(self._subsonic, coverArt) if coverArt else None 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 @@ -219,27 +215,31 @@ def __init__( 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.genres = ( + [ItemGenre(self._subsonic, **genre) for genre in genres] if genres else None + ) self.artists = ( - [Artist(self.__subsonic, **artist) for artist in 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] + [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] + [Contributor(self._subsonic, **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 + self.replay_gain = ( + ReplayGain(self._subsonic, **replayGain) if replayGain else None + ) def generate(self) -> "Song": """Return a new song with all the data updated from the API, @@ -252,7 +252,7 @@ def generate(self) -> "Song": :rtype: Song """ - return self.__subsonic.browsing.get_song(self.id) + return self._subsonic.browsing.get_song(self.id) def star(self) -> Self: """Calls the "star" endpoint of the API. @@ -261,7 +261,7 @@ def star(self) -> Self: :rtype: Self """ - self.__subsonic.media_annotation.star_song(self.id) + self._subsonic.media_annotation.star_song(self.id) return self @@ -272,7 +272,7 @@ def unstar(self) -> Self: :rtype: Self """ - self.__subsonic.media_annotation.unstar_song(self.id) + self._subsonic.media_annotation.unstar_song(self.id) return self @@ -285,7 +285,7 @@ def set_rating(self, rating: int) -> Self: :rtype: Self """ - self.__subsonic.media_annotation.set_rating(self.id, rating) + self._subsonic.media_annotation.set_rating(self.id, rating) return self @@ -296,7 +296,7 @@ def remove_rating(self) -> Self: :rtype: Self """ - self.__subsonic.media_annotation.remove_rating(self.id) + self._subsonic.media_annotation.remove_rating(self.id) return self @@ -307,6 +307,6 @@ def scrobble(self, time: datetime, submission: bool = True) -> Self: :rtype: Self """ - self.__subsonic.media_annotation.scrobble([self.id], [time], submission) + self._subsonic.media_annotation.scrobble([self.id], [time], submission) return self diff --git a/src/knuckles/models/system.py b/src/knuckles/models/system.py index 80d1b55..41871a4 100644 --- a/src/knuckles/models/system.py +++ b/src/knuckles/models/system.py @@ -1,15 +1,21 @@ from datetime import datetime +from typing import TYPE_CHECKING from dateutil import parser +from knuckles.models.model import Model +if TYPE_CHECKING: + from ..subsonic import Subsonic -class SubsonicResponse: + +class SubsonicResponse(Model): """Representation of the generic successful response data in a request to the API. """ def __init__( self, + subsonic: "Subsonic", status: str, version: str, type: str | None = None, @@ -34,6 +40,8 @@ def __init__( :type openSubsonic: bool, optional """ + super().__init__(subsonic) + self.status: str = status self.version: str = version self.type: str | None = type @@ -41,11 +49,12 @@ def __init__( self.open_subsonic: bool = openSubsonic -class License: +class License(Model): """Representation of the license related data in Subsonic.""" def __init__( self, + subsonic: "Subsonic", valid: bool, email: str | None = None, licenseExpires: str | None = None, @@ -63,6 +72,8 @@ def __init__( :type trialExpires: str | None, optional """ + super().__init__(subsonic) + self.valid: bool = valid self.email: str | None = email diff --git a/src/knuckles/models/user.py b/src/knuckles/models/user.py index 1e85836..a44e5d0 100644 --- a/src/knuckles/models/user.py +++ b/src/knuckles/models/user.py @@ -1,19 +1,19 @@ from typing import TYPE_CHECKING, Self +from .model import Model + if TYPE_CHECKING: from ..subsonic import Subsonic -from ..exceptions import NoApiAccess +from ..exceptions import MissingRequiredProperty -class User: +class User(Model): """Representation of all the data related to a user in Subsonic.""" def __init__( self, - # Internal subsonic: "Subsonic", - # Subsonic fields username: str, password: str | None = None, email: str | None = None, @@ -33,7 +33,9 @@ def __init__( music_folder_id: list[str] | None = None, max_bit_rate: int | None = None, ) -> None: - self.subsonic = subsonic + + super().__init__(subsonic) + self.username = username self.password = password self.email = email @@ -53,20 +55,6 @@ def __init__( self.music_folder_id = music_folder_id self.max_bit_rate = max_bit_rate - def __check_api_access(self) -> None: - """Check if the object has a valid subsonic property - - :raises NoApiAccess: _description_ - """ - - if self.subsonic is None: - raise NoApiAccess( - ( - "This user isn't associated with a Subsonic object." - + "A non None value in the subsonic property is required" - ) - ) - def generate(self) -> "User": """Returns the function to the same user with the maximum possible information from the Subsonic API. @@ -79,9 +67,7 @@ def generate(self) -> "User": :rtype: User """ - self.__check_api_access() - - return self.subsonic.user_management.get_user(self.username) + return self._subsonic.user_management.get_user(self.username) def create(self) -> Self: """Calls the "createUser" endpoint of the API. @@ -91,13 +77,17 @@ def create(self) -> Self: :rtype: Self """ - self.__check_api_access() + if not self.email: + raise MissingRequiredProperty( + "You must provide an email in the email property of the model" + ) - #! TODO This is bad - if not self.password or not self.email: - raise NoApiAccess() + if not self.password: + raise MissingRequiredProperty( + "You must provide an password in the password property of the model" + ) - self.subsonic.user_management.create_user( + self._subsonic.user_management.create_user( self.username, self.password, self.email, @@ -131,9 +121,7 @@ def update(self) -> Self: :rtype: Self """ - self.__check_api_access() - - self.subsonic.user_management.update_user( + self._subsonic.user_management.update_user( self.username, self.password, self.email, @@ -164,9 +152,7 @@ def delete(self) -> Self: :rtype: Self """ - self.__check_api_access() - - self.subsonic.user_management.delete_user(self.username) + self._subsonic.user_management.delete_user(self.username) return self @@ -182,9 +168,6 @@ def change_password(self, new_password: str) -> Self: :return: The object itself to allow method chaining. :rtype: Self """ - - self.__check_api_access() - - self.subsonic.user_management.change_password(self.username, new_password) + self._subsonic.user_management.change_password(self.username, new_password) return self diff --git a/src/knuckles/subsonic.py b/src/knuckles/subsonic.py index 9d2276d..3030ff2 100644 --- a/src/knuckles/subsonic.py +++ b/src/knuckles/subsonic.py @@ -49,7 +49,7 @@ def __init__( self.api = Api( url, user, password, client, use_https, use_token, request_method ) - self.system = System(self.api) + self.system = System(self.api, self) self.browsing = Browsing(self.api, self) self.lists = None # !! self.searching = Searching(self.api, self) @@ -63,5 +63,4 @@ def __init__( self.chat = Chat(self.api, self) self.user_management = UserManagement(self.api, self) self.bookmarks = Bookmarks(self.api, self) - self.media_library_scanning = MediaLibraryScanning(self.api) - self.media_library_scanning = MediaLibraryScanning(self.api) + self.media_library_scanning = MediaLibraryScanning(self.api, self) diff --git a/src/knuckles/system.py b/src/knuckles/system.py index e80bd37..a8706a6 100644 --- a/src/knuckles/system.py +++ b/src/knuckles/system.py @@ -1,8 +1,11 @@ -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple from .api import Api from .models.system import License, SubsonicResponse +if TYPE_CHECKING: + from .subsonic import Subsonic + class OpenSubsonicExtension(NamedTuple): name: str @@ -15,9 +18,12 @@ class System: """ - def __init__(self, api: Api) -> None: + def __init__(self, api: Api, subsonic: "Subsonic") -> None: self.api = api + # Only to pass it to the models + self.subsonic = subsonic + def ping(self) -> SubsonicResponse: """Calls to the "ping" endpoint of the API. @@ -29,7 +35,7 @@ def ping(self) -> SubsonicResponse: response = self.api.json_request("ping") - return SubsonicResponse(**response) + return SubsonicResponse(self.subsonic, **response) def get_license(self) -> License: """Calls to the "getLicense" endpoint of the API. @@ -40,7 +46,7 @@ def get_license(self) -> License: response = self.api.json_request("getLicense")["license"] - return License(**response) + return License(self.subsonic, **response) def get_open_subsonic_extensions(self) -> list[OpenSubsonicExtension]: response = self.api.json_request("getOpenSubsonicExtensions")[ diff --git a/tests/models/test_share.py b/tests/models/test_share.py index 01890c6..d60b3be 100644 --- a/tests/models/test_share.py +++ b/tests/models/test_share.py @@ -38,7 +38,7 @@ def test_generate_nonexistent_genre( with pytest.raises( ResourceNotFound, - match="Unable to generate share as it does not exist in the server", + match="Unable to generate the model as it does not exist in the server", ): nonexistent_share.generate()