made playing info a custom widget and updated the subsonic api library
This commit is contained in:
1 parent
6c4e9c40b1
commit
110c7f3425
3 files changed
+139
-106
No files matched your search
+128
-105
@@ -5,9 +5,6 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import xml.etree
|
|
||||||
import xml.etree.ElementTree
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -110,58 +107,17 @@ class Artist:
|
|||||||
self.coverArt = coverArt
|
self.coverArt = coverArt
|
||||||
self.artistImageUrl = artistImageUrl
|
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 ArtistIndex:
|
class BadSubsonicResponse(Exception):
|
||||||
name: str
|
pass
|
||||||
artists: list[Artist]
|
|
||||||
|
|
||||||
def __init__(self, indexInfo: dict, artists: list[dict]):
|
|
||||||
self.name = indexInfo['name']
|
|
||||||
|
|
||||||
self.artists = []
|
|
||||||
for artist in artists:
|
|
||||||
self.artists.append(Artist(
|
|
||||||
artist['id'],
|
|
||||||
artist['name'],
|
|
||||||
artist['albumCount'],
|
|
||||||
artist['coverArt'],
|
|
||||||
artist['artistImageUrl']
|
|
||||||
))
|
|
||||||
|
|
||||||
class ArtistIndexes:
|
|
||||||
indexes: list[ArtistIndex]
|
|
||||||
|
|
||||||
def __init__(self, xml_data: bytes):
|
|
||||||
self._parseXml(xml_data.decode('utf-8'))
|
|
||||||
|
|
||||||
def _parseXml(self, xml_data: str):
|
|
||||||
root = xml.etree.ElementTree.fromstring(xml_data)
|
|
||||||
|
|
||||||
self.indexes: list[ArtistIndex] = []
|
|
||||||
|
|
||||||
if root.get('status') != 'ok':
|
|
||||||
return None
|
|
||||||
|
|
||||||
in_index = False
|
|
||||||
artists = []
|
|
||||||
index_info = None
|
|
||||||
for item in root.iter():
|
|
||||||
# print(item.tag, dict(item.items()))
|
|
||||||
|
|
||||||
if item.tag == '{http://subsonic.org/restapi}index':
|
|
||||||
|
|
||||||
if in_index:
|
|
||||||
in_index = not in_index
|
|
||||||
|
|
||||||
self.indexes.append(ArtistIndex(index_info, artists))
|
|
||||||
|
|
||||||
artists = []
|
|
||||||
else:
|
|
||||||
in_index = not in_index
|
|
||||||
index_info = dict(item.items())
|
|
||||||
|
|
||||||
elif item.tag == '{http://subsonic.org/restapi}artist':
|
|
||||||
artists.append(dict(item.items()))
|
|
||||||
|
|
||||||
class SubpyConn:
|
class SubpyConn:
|
||||||
username: str
|
username: str
|
||||||
@@ -192,17 +148,13 @@ class SubpyConn:
|
|||||||
"t" : self._hashPass(),
|
"t" : self._hashPass(),
|
||||||
"s" : self.salt,
|
"s" : self.salt,
|
||||||
"v" : self.version,
|
"v" : self.version,
|
||||||
"c" : self.client_name
|
"c" : self.client_name,
|
||||||
|
"f": "json"
|
||||||
}
|
}
|
||||||
|
|
||||||
def _encodePayload(self, payload: dict) -> str:
|
def _encodePayload(self, payload: dict) -> str:
|
||||||
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus)
|
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus)
|
||||||
|
|
||||||
def getIndexes(self) -> ArtistIndexes:
|
|
||||||
url = self.base_url + '/rest/getIndexes?'
|
|
||||||
|
|
||||||
return ArtistIndexes(requests.get(url + self._encodePayload(self._getBasePayload())).content)
|
|
||||||
|
|
||||||
def _getMusicDirectory(self, id: str):
|
def _getMusicDirectory(self, id: str):
|
||||||
url = self.base_url + '/rest/getMusicDirectory?'
|
url = self.base_url + '/rest/getMusicDirectory?'
|
||||||
|
|
||||||
@@ -212,48 +164,44 @@ class SubpyConn:
|
|||||||
return requests.get(url + self._encodePayload(payload))
|
return requests.get(url + self._encodePayload(payload))
|
||||||
|
|
||||||
def getArtistAlbums(self, artist_id: str) -> list[Album]:
|
def getArtistAlbums(self, artist_id: str) -> list[Album]:
|
||||||
r: requests.Response = self._getMusicDirectory(artist_id)
|
response: requests.Response = self._getMusicDirectory(artist_id).json()['subsonic-response']
|
||||||
xml_data: str = r.content.decode('utf-8')
|
|
||||||
|
if response['status'] != 'ok':
|
||||||
|
raise BadSubsonicResponse()
|
||||||
|
|
||||||
root: xml.etree.ElementTree.Element = xml.etree.ElementTree.fromstring(xml_data)
|
|
||||||
albums: list[Album] = []
|
albums: list[Album] = []
|
||||||
|
for item in response['directory']['child']:
|
||||||
for item in root.iter():
|
albums.append(Album(item))
|
||||||
if item.tag == '{http://subsonic.org/restapi}child':
|
|
||||||
albums.append(Album(dict(item.items())))
|
|
||||||
|
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
def getAlbumSongs(self, album_id: str) -> list[Song]:
|
def getAlbumSongs(self, album_id: str) -> list[Song]:
|
||||||
r: requests.Response = self._getMusicDirectory(album_id)
|
response: requests.Response = self._getMusicDirectory(album_id).json()['subsonic-response']
|
||||||
logger.debug(f"Requesting all songs for album {album_id} using url {self.base_url}")
|
|
||||||
logger.debug(f'Got response with status code {r.status_code}')
|
|
||||||
|
|
||||||
xml_data: str = r.content.decode('utf-8')
|
if response['status'] != 'ok':
|
||||||
|
raise BadSubsonicResponse()
|
||||||
|
|
||||||
if r.status_code != 200:
|
|
||||||
return []
|
|
||||||
|
|
||||||
root: xml.etree.ElementTree.Element = xml.etree.ElementTree.fromstring(xml_data)
|
|
||||||
songs: list[Song] = []
|
songs: list[Song] = []
|
||||||
|
for item in response['directory']['child']:
|
||||||
logger.debug(f"Request Status: {root.get('status')}")
|
songs.append(Song(item))
|
||||||
if root.get('status') != 'ok':
|
|
||||||
return []
|
|
||||||
|
|
||||||
for item in root.iter():
|
|
||||||
|
|
||||||
if item.tag == '{http://subsonic.org/restapi}child':
|
|
||||||
songs.append(Song(dict(item.items())))
|
|
||||||
|
|
||||||
return songs
|
return songs
|
||||||
|
|
||||||
def getArtists(self) -> list[Artist]:
|
def getArtists(self) -> list[Artist]:
|
||||||
artistIndexes: ArtistIndexes = self.getIndexes()
|
url = self.base_url + '/rest/getArtist?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response']
|
||||||
|
|
||||||
|
if response['status'] != 'ok':
|
||||||
|
raise BadSubsonicResponse()
|
||||||
|
|
||||||
artists: list[Artist] = []
|
artists: list[Artist] = []
|
||||||
for artistIndex in artistIndexes.indexes:
|
|
||||||
artists.extend(artistIndex.artists)
|
for artist_index in response['artists']['index']:
|
||||||
|
for artist in artist_index['artist']:
|
||||||
|
artists.append(Artist.from_dict(artist))
|
||||||
|
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
@@ -264,7 +212,6 @@ class SubpyConn:
|
|||||||
payload['type'] = 'alphabeticalByName'
|
payload['type'] = 'alphabeticalByName'
|
||||||
payload['size'] = size
|
payload['size'] = size
|
||||||
payload['offset'] = offset
|
payload['offset'] = offset
|
||||||
payload['f'] = 'json'
|
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
r = requests.get(url + self._encodePayload(payload))
|
||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
@@ -293,7 +240,6 @@ class SubpyConn:
|
|||||||
url = self.base_url + "/rest/getPlaylists?"
|
url = self.base_url + "/rest/getPlaylists?"
|
||||||
|
|
||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
payload['f'] = 'json'
|
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
r = requests.get(url + self._encodePayload(payload))
|
||||||
|
|
||||||
@@ -309,7 +255,6 @@ class SubpyConn:
|
|||||||
url = self.base_url + "/rest/getPlaylist?"
|
url = self.base_url + "/rest/getPlaylist?"
|
||||||
|
|
||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
payload['f'] = 'json'
|
|
||||||
payload['id'] = playlist_id
|
payload['id'] = playlist_id
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
r = requests.get(url + self._encodePayload(payload))
|
||||||
@@ -326,7 +271,6 @@ class SubpyConn:
|
|||||||
url = self.base_url + "/rest/getStarred?"
|
url = self.base_url + "/rest/getStarred?"
|
||||||
|
|
||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
payload['f'] = 'json'
|
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
r = requests.get(url + self._encodePayload(payload))
|
||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
@@ -336,13 +280,7 @@ class SubpyConn:
|
|||||||
starred.append(Album(album))
|
starred.append(Album(album))
|
||||||
|
|
||||||
for artist in json_r['subsonic-response']['starred']['artist']:
|
for artist in json_r['subsonic-response']['starred']['artist']:
|
||||||
starred.append(Artist(
|
starred.append(Artist.from_dict(artist))
|
||||||
artist['id'],
|
|
||||||
artist['name'],
|
|
||||||
artist['albumCount'],
|
|
||||||
artist['coverArt'],
|
|
||||||
artist['artistImageUrl']
|
|
||||||
))
|
|
||||||
|
|
||||||
for song in json_r['subsonic-response']['starred']['song']:
|
for song in json_r['subsonic-response']['starred']['song']:
|
||||||
starred.append(Song(song))
|
starred.append(Song(song))
|
||||||
@@ -353,7 +291,6 @@ class SubpyConn:
|
|||||||
url = self.base_url + "/rest/getStarred?"
|
url = self.base_url + "/rest/getStarred?"
|
||||||
|
|
||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
payload['f'] = 'json'
|
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
r = requests.get(url + self._encodePayload(payload))
|
||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
@@ -402,13 +339,99 @@ class SubpyConn:
|
|||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
payload['id'] = song_id
|
payload['id'] = song_id
|
||||||
|
|
||||||
r = requests.get(url + self._encodePayload(payload))
|
response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response']
|
||||||
|
|
||||||
xml_data = r.content.decode('utf-8')
|
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/scrobble/
|
||||||
|
|
||||||
|
id: str - unique identifier of the media to scrobble
|
||||||
|
OPTIONAL time: int - posix time of when the song was listened to
|
||||||
|
OPTIONAL submission: bool - Whether this is a "submission" or a "now playing" notification. Default: True
|
||||||
|
"""
|
||||||
|
def scrobble(self, id: str, time: int | None = None, submission: bool | None = True) -> None:
|
||||||
|
url = self.base_url + '/rest/scrobble?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
payload['id'] = id
|
||||||
|
|
||||||
|
if time:
|
||||||
|
payload['time'] = 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
|
||||||
|
|
||||||
root = xml.etree.ElementTree.fromstring(xml_data)
|
|
||||||
|
|
||||||
for item in root.iter():
|
|
||||||
if item.tag == '{http://subsonic.org/restapi}song':
|
|
||||||
return Song(dict(item.items()))
|
|
||||||
|
|
||||||
@@ -340,6 +340,7 @@ class PlayingInfo(Widget):
|
|||||||
|
|
||||||
table.add_column("")
|
table.add_column("")
|
||||||
table.add_column("")
|
table.add_column("")
|
||||||
|
table.show_header = False
|
||||||
|
|
||||||
if app.currently_playing:
|
if app.currently_playing:
|
||||||
song = app.currently_playing
|
song = app.currently_playing
|
||||||
@@ -351,6 +352,8 @@ class PlayingInfo(Widget):
|
|||||||
table.add_row("Track Number", str(song.trackNumber))
|
table.add_row("Track Number", str(song.trackNumber))
|
||||||
table.add_row("Bit Rate", str(song.bitRate))
|
table.add_row("Bit Rate", str(song.bitRate))
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
class MainView(Screen):
|
class MainView(Screen):
|
||||||
CSS_PATH = 'main.tcss'
|
CSS_PATH = 'main.tcss'
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -1 +1,8 @@
|
|||||||
[2025-01-29 17:02:16,865] DEBUG in selector_events: Using selector: EpollSelector
|
[2025-01-31 13:17:33,930] DEBUG in selector_events: Using selector: EpollSelector
|
||||||
|
[2025-01-31 13:17:40,998] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443
|
||||||
|
[2025-01-31 13:17:41,453] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getPlaylists?u=pseudo&t=26045eee6c481e32f1f1876d416249c8&s=CQ0WT5Y4&v=1.16.1&c=testing&f=json HTTP/1.1" 200 712
|
||||||
|
[2025-01-31 13:17:42,466] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443
|
||||||
|
[2025-01-31 13:17:42,708] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getPlaylist?u=pseudo&t=830bf3dc3c1d2cab4c2ac57488e70f11&s=RK19ZT46&v=1.16.1&c=testing&f=json&id=e6e8ff27-2a60-4e33-8aa1-09586191166a HTTP/1.1" 200 None
|
||||||
|
[2025-01-31 13:17:50,183] DEBUG in screens-test: Playing song Gilgamesh by King Gizzard & The Lizard Wizard.
|
||||||
|
[2025-01-31 13:17:50,186] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443
|
||||||
|
[2025-01-31 13:17:50,602] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/stream?u=pseudo&t=eb21ada2bb8e6f8bdc7cc92469db2605&s=D0INLK0D&v=1.16.1&c=testing&f=json&id=8033582249221c9c67cc57da9e0aba14 HTTP/1.1" 200 None
|
||||||
Reference in new issue
Block a user