import time import random import string import hashlib import logging import urllib.parse import requests logger = logging.getLogger(__name__) class Playlist: id: str name: str songCount: int duration: str owner: str coverArtId: str def __init__(self, info: dict): self.id = info['id'] self.name = info['name'] self.songCount = int(info['songCount']) self.duration = time.strftime('%H:%M:%S', time.gmtime(int(info['duration']))) self.owner = info['owner'] self.coverArtId = info['coverArt'] class Album: id: str title: str name: str album: str artist: str coverArtId: str | None duration: int artistId: str songCount: int playCount: int year: int | None duration_f: str def __init__(self, album_info: dict): self.id = album_info['id'] self.name = album_info['name'] self.title = album_info['title'] if 'title' in album_info else self.name self.album = album_info['album'] if 'album' in album_info else self.name self.artist = album_info.get('artist') if not self.artist: self.artist = album_info.get('albumArtist') self.coverArtId = album_info['coverArt'] if 'coverArt' in album_info else None self.duration = int(album_info['duration']) self.artistId = album_info.get('albumArtistId') self.songCount = int(album_info['songCount']) self.playCount = int(album_info['playCount']) if 'playCount' in album_info else 0 self.year = int(album_info['year']) if 'year' in album_info else None self.duration_f = self.duration = time.strftime('%H:%M:%S', time.gmtime(int(album_info['duration']))) class Song: id: str title: str album: str artist: str trackNumber: int contentType: str suffix: str duration: int bitRate: int path: str discNumber: int albumId: str artistId: str comment: str channelCount: int samplingRate: int playCount: int | None coverArtId: str | None # Navidrome Fields rating: int | None def __init__(self, info: dict | None = None): if info is None: info = {} self.id = info['id'] if 'id' in info else "" self.title = info['title'] if 'title' in info else "" self.album = info['album'] if 'album' in info else "" self.artist = info['artist'] if 'artist' in info else "" self.trackNumber = int(info['track']) if 'track' in info else 0 self.contentType = info['contentType'] if 'contentType' in info else "" self.suffix = info['suffix'] if 'suffix' in info else "" self.duration = int(info['duration']) if 'duration' in info else 0 self.bitRate = int(info['bitRate']) if 'bitRate' in info else "" self.path = info['path'] if 'path' in info else "" self.discNumber = int(info['discNumber']) if 'discNumber' in info else "" self.albumId = info['albumId'] if 'albumId' in info else "" self.artistId = info['artistId'] if 'artistId' in info else "" self.comment = info['comment'] if 'comment' in info else "" self.channelCount = int(info['channelCount']) if 'channelCount' in info else "" self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else "" self.playCount = int(info['playCount']) if 'playCount' in info else "" self.coverArtId = info['coverArt'] if 'coverArt' in info else None self.rating = info.get('rating') class Artist: id: str name: str albumCount: int coverArt: str artistImageUrl: str def __init__(self, id: str, name: str, albumCount: str | int, coverArt: str, artistImageUrl: str): self.id = id self.name = name self.albumCount = int(albumCount) self.coverArt = coverArt self.artistImageUrl = artistImageUrl def from_dict(info: dict): return Artist( info['id'] if 'id' in info else None, info['name'] if 'name' in info else None, info['albumCount'] if 'albumCount' in info else None, info['coverArt'] if 'coverArt' in info else None, info['artistImageUrl'] if 'artistImageUrl' in info else None ) class BadSubsonicResponse(Exception): pass class SubpyConn: username: str password: str base_url: str salt: str salt_len: int = 8 version: str = "1.16.1" client_name: str = "SubSonic Python API Lib" def __init__(self, u: str, p: str, base_url: str, salt_len: int | None = 8, client_name: str | None = "SubSonic Python API Lib"): self.username = u self.password = p self.base_url = base_url self.client_name = client_name self.salt_len = salt_len def _getBasePayload(self) -> dict: salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len)) token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest() return { "u": self.username, "t": token, "s": salt, "v": self.version, "c": self.client_name, "f": "json" } def _encodePayload(self, payload: dict) -> str: return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus, doseq=True) def _getMusicDirectory(self, id: str): url = self.base_url + '/rest/getMusicDirectory?' payload = self._getBasePayload() payload['id'] = id return requests.get(url + self._encodePayload(payload)) def getArtistAlbums(self, artist_id: str) -> list[Album]: response: requests.Response = self._getMusicDirectory(artist_id).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() albums: list[Album] = [] for item in response['directory']['child']: albums.append(Album(item)) return albums def getAlbumSongs(self, album_id: str) -> list[Song]: response: requests.Response = self._getMusicDirectory(album_id).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() songs: list[Song] = [] for item in response['directory']['child']: songs.append(Song(item)) return songs def getArtists(self) -> list[Artist]: url = self.base_url + '/rest/getArtists?' payload = self._getBasePayload() response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() artists: list[Artist] = [] for artist_index in response['artists']['index']: for artist in artist_index['artist']: artists.append(Artist.from_dict(artist)) return artists def getAlbums(self, size: int | None = 25, offset: int | None = 0) -> list[Album]: url = self.base_url + '/rest/getAlbumList?' payload = self._getBasePayload() payload['type'] = 'alphabeticalByName' payload['size'] = size payload['offset'] = offset r = requests.get(url + self._encodePayload(payload)) json_r = r.json() albums: list[Album] = [] if 'album' not in json_r['subsonic-response']['albumList']: return [] for album in json_r['subsonic-response']['albumList']['album']: albums.append(Album(album)) return albums def getAllAlbums(self) -> list[Album]: all_albums: list[Album] = [] offset = 0 while albums := self.getAlbums(50, offset): all_albums.extend(albums) offset += 50 return all_albums def getPlaylists(self) -> list[Playlist]: url = self.base_url + "/rest/getPlaylists?" payload = self._getBasePayload() r = requests.get(url + self._encodePayload(payload)) json_r = r.json() playlists: list[Playlist] = [] for playlist in json_r['subsonic-response']['playlists']['playlist']: playlists.append(Playlist(playlist)) return playlists def getPlaylist(self, playlist_id: str) -> list[Song]: url = self.base_url + "/rest/getPlaylist?" payload = self._getBasePayload() payload['id'] = playlist_id r = requests.get(url + self._encodePayload(payload)) json_r = r.json() if 'entry' not in json_r['subsonic-response']['playlist']: return [] songs: list[Song] = [] for song in json_r['subsonic-response']['playlist']['entry']: songs.append(Song(song)) return songs def getStarred(self) -> list[Album | Song | Artist]: url = self.base_url + "/rest/getStarred?" payload = self._getBasePayload() r = requests.get(url + self._encodePayload(payload)) json_r = r.json() starred: list[Album | Song | Artist] = [] for album in json_r['subsonic-response']['starred']['album']: starred.append(Album(album)) for artist in json_r['subsonic-response']['starred']['artist']: starred.append(Artist.from_dict(artist)) for song in json_r['subsonic-response']['starred']['song']: starred.append(Song(song)) return starred def getStarredSongs(self) -> list[Song]: url = self.base_url + "/rest/getStarred?" payload = self._getBasePayload() r = requests.get(url + self._encodePayload(payload)) json_r = r.json() starred: list[Song] = [] for song in json_r['subsonic-response']['starred']['song']: starred.append(Song(song)) return starred # Returns a requests Response object, the data stream can be found in Response.content def stream(self, song_id: str, maxBitRate: int | None = None, format: str | None = None) -> requests.Response: url = self.base_url + '/rest/stream?' payload = self._getBasePayload() if maxBitRate: payload['maxBitRate'] = maxBitRate if format: payload['format'] = format payload['id'] = song_id return requests.get(url + self._encodePayload(payload), stream=True) # Saves cover art id def getCoverArt(self, cover_art_id: str, size: int | None = None, path: str | None = None) -> None: url = self.base_url + '/rest/getCoverArt?' payload = self._getBasePayload() payload['id'] = cover_art_id if size: payload['size'] = size r = requests.get(url + self._encodePayload(payload)) with open(path if path else 'cover.png', 'wb') as file: file.write(r.content) def getCoverArtRaw(self, cover_art_id: str, size: int | None = None) -> bytes: url = self.base_url + '/rest/getCoverArt?' payload = self._getBasePayload() payload['id'] = cover_art_id if size: payload['size'] = size r = requests.get(url + self._encodePayload(payload)) return r.content def getSong(self, song_id: str) -> Song: url = self.base_url + '/rest/getSong?' payload = self._getBasePayload() payload['id'] = song_id response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() return Song(response['song']) def star(self, id: str) -> None: url = self.base_url + '/rest/star?' payload = self._getBasePayload() payload['id'] = id response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() def unstar(self, id: str) -> None: url = self.base_url + '/rest/unstar?' payload = self._getBasePayload() payload['id'] = id response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() """ https://opensubsonic.netlify.app/docs/endpoints/setrating/ id A string which uniquely identifies the file (song) or folder (album/artist) to rate. rating The rating between 1 and 5 (inclusive), or 0 to remove the rating: only accepts ints """ def setRating(self, id: str, rating: int) -> None: url = self.base_url + '/rest/setRating?' payload = self._getBasePayload() payload['id'] = id payload['rating'] = rating requests.get(url + self._encodePayload(payload)) """ https://opensubsonic.netlify.app/docs/endpoints/scrobble/ id: str - unique identifier of the media to scrobble OPTIONAL scrobble_time: int - posix time of when the song was listened to. Default: Current Time (UNIX_E) OPTIONAL submission: bool - Whether this is a "submission" or a "now playing" notification. Default: True """ def scrobble(self, id: str, scrobble_time: int | None = None, submission: bool | None = True) -> None: url = self.base_url + '/rest/scrobble?' payload = self._getBasePayload() payload['id'] = id if scrobble_time is None: payload['time'] = round(time.time() * 1000) elif scrobble_time: payload['time'] = scrobble_time payload['submission'] = submission response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() """ https://opensubsonic.netlify.app/docs/endpoints/getrandomsongs/ """ def getRandomSongs(self, size: int | None = 10, genre: str | None = None, fromYear: str | int | None = None, toYear: str | int | None = None, musicFolderId: str | int | None = None ) -> list[Song]: url = self.base_url + '/rest/getRandomSongs?' payload = self._getBasePayload() # The default being 10 is only to make the method match the internal docs if size and size != 10: payload['size'] = size if genre: payload['genre'] = genre if fromYear: payload['fromYear'] = fromYear if toYear: payload['toYear'] = toYear if musicFolderId: payload['musicFolderId'] = musicFolderId response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] if response['status'] != 'ok': raise BadSubsonicResponse() random_songs: list[Song] = [] for song in response['randomSongs']['song']: random_songs.append(Song(song)) return random_songs """ https://www.subsonic.org/pages/api.jsp#getLyrics """ def getLyrics(self, artist: str, title: str): url = self.base_url + '/rest/getLyrics?' payload = self._getBasePayload() payload['artist'] = artist payload['title'] = title response = requests.get(url + self._encodePayload(payload)).json() return response """ https://www.subsonic.org/pages/api.jsp#createPlaylist songs (Optional): can either be a list of songId's as a string OR a list of SubPy Song Objects """ def createPlaylist(self, name: str, songs: list[str] | list[Song] | None = None) -> Playlist: url = self.base_url + '/rest/createPlaylist?' payload = self._getBasePayload() payload['name'] = name response = requests.get(url + self._encodePayload(payload)).json() return Playlist(response['subsonic-response']['playlist']) """ https://www.subsonic.org/pages/api.jsp#updatePlaylist returns True on success """ def updatePlaylist(self, playlistId: str, name: str | None = None, comment: str | None = None, public: bool | None = None, songIdToAdd: list[str] | None = None) -> bool: url = self.base_url + '/rest/updatePlaylist?' payload = self._getBasePayload() payload['playlistId'] = playlistId if songIdToAdd: payload['songIdToAdd'] = songIdToAdd response = requests.get(url + self._encodePayload(payload)).json() return bool(response['subsonic-response']) """ https://www.subsonic.org/pages/api.jsp#createShare Params: m_id: ID of a song, album or video to share. Use one id parameter for each entry to share. description (OPTIONAL): A user-defined description that will be displayed to people visiting the shared media. expires (OPTIONAL): The time at which the share expires. Given as milliseconds since 1970. Default is a year. Returns: List of share links? TODO @0x01FE : Finish this method """ def createShare(self, m_id: str, description: str | None = None, expires: int | None = None) -> list[str]: url = self.base_url + '/rest/createShare?' payload = self._getBasePayload() payload['id'] = m_id if expires: payload['expires'] = expires response = requests.get(url + self._encodePayload(payload)).json() return response['subsonic-response']['shares']['share'][0]['url']