Compare commits
16
Commits
c0b99d8e1e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2295af7b56 | ||
|
|
d3de6ee914 | ||
|
|
0ca2547b54 | ||
|
|
4c6976b2df | ||
|
|
9c462679c8 | ||
|
|
a213e971e6 | ||
|
|
4d413a24a3 | ||
|
|
48c9aca87e | ||
|
|
1e4616d498 | ||
|
|
a2e53f1bc7 | ||
|
|
4aba71cf2c | ||
|
|
26c9612648 | ||
|
|
d601d11e4b | ||
|
|
3e07ef0806 | ||
|
|
7bb50b64c7 | ||
|
|
ada1ef5fba |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
__pycache__
|
__pycache__/
|
||||||
.cache/
|
.cache/
|
||||||
|
examples/
|
||||||
|
.venv/
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
auth
|
auth
|
||||||
|
test.py
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import SubSonic
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Navidrome has some API endpoints that are not included in the SubSonic or OpenSubSonic standards.
|
||||||
|
Nor are they documented anywhere I can find, the NavidromeAPI class is a collection of methods to interact with this hidden API.
|
||||||
|
The endpoints are reverse engineered from observing requests made by the Navidrome server webUI.
|
||||||
|
"""
|
||||||
|
class NavidromeAPI:
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
auth_token: str
|
||||||
|
auth_header: dict = {}
|
||||||
|
|
||||||
|
def __init__(self, u: str, p: str, base_url: str):
|
||||||
|
self.username = u
|
||||||
|
self.password = p
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
self.auth_token = self._login()
|
||||||
|
self.auth_header['X-Nd-Authorization'] = f"Bearer {self.auth_token}"
|
||||||
|
|
||||||
|
def _encodePayload(self, payload: dict) -> str:
|
||||||
|
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus)
|
||||||
|
|
||||||
|
def _login(self) -> str:
|
||||||
|
url = self.base_url + '/auth/login'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'username': self.username,
|
||||||
|
'password': self.password
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=payload)
|
||||||
|
|
||||||
|
j = response.json()
|
||||||
|
|
||||||
|
return j['token']
|
||||||
|
|
||||||
|
def getAllSongs(self, end: int, start: int, order: str = 'ASC', sort: str = 'title') -> list[SubSonic.Song]:
|
||||||
|
url = self.base_url + '/api/song?'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"_end": end,
|
||||||
|
"_order": order,
|
||||||
|
"_sort": sort,
|
||||||
|
"_start": start
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
|
||||||
|
|
||||||
|
return [SubSonic.Song(song_d) for song_d in response.json()]
|
||||||
|
|
||||||
|
def getAlbums(self, end: int, start: int, order: str = 'ACS', sort: str = 'name', name: str | None = None, seed: float | None = None) -> list[SubSonic.Album]:
|
||||||
|
url = self.base_url + '/api/album?'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"_end": end,
|
||||||
|
"_order": order,
|
||||||
|
"_sort" : sort,
|
||||||
|
"_start": start,
|
||||||
|
"seed": 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if seed:
|
||||||
|
payload['seed'] = seed
|
||||||
|
|
||||||
|
if name:
|
||||||
|
payload['name'] = name
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
|
||||||
|
|
||||||
|
print(response.content)
|
||||||
|
|
||||||
|
return [SubSonic.Album(album_d) for album_d in response.json()]
|
||||||
|
|
||||||
|
def getSong(self, id: str):
|
||||||
|
url = self.base_url + '/api/song?'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id" : id
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def isSongStarred(self, id: str) -> bool:
|
||||||
|
info = self.getSong(id)
|
||||||
|
|
||||||
|
logging.info(info)
|
||||||
|
|
||||||
|
return info[0].get('starred') if info[0].get('starred') else False
|
||||||
|
|
||||||
|
"""
|
||||||
|
A strange one. Used for reordering tracks on a playlist, since it's not a native SubSonic thing.
|
||||||
|
playlist_id (str): Id of the playlist you're updating
|
||||||
|
song_index (int): Track you want to move
|
||||||
|
insert_before_index (int): For example, if you want to move a track to the start of a playlist, this is 1.
|
||||||
|
|
||||||
|
RETURNS: index of the editting song (song_index)
|
||||||
|
"""
|
||||||
|
def playlistInsertTrackBefore(self, playlist_id: str, song_index: int, insert_before_index: int) -> int:
|
||||||
|
url = self.base_url + f'/api/playlist/{playlist_id}/tracks/{song_index}'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"insert_before" : str(insert_before_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=self.auth_header, json=payload)
|
||||||
|
|
||||||
|
return response['id']
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SubPy (Name pending)
|
# SubPy (Name pending)
|
||||||
|
|
||||||
SubPy is a terminal user interface (TUI) music player written in Python primarly using the Textual library. SubPy connects to a SubSonic server using the SubSonic API to stream music to your machine.
|
SubPy is a terminal user interface (TUI) music player written in Python primarly using the Textual library. SubPy connects to a Navidrome server using its API to stream music to your machine. Currently the player is designed to work with Navidrome 0.60.3.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
@@ -18,9 +18,13 @@ This program requires a file simply named `auth` that contains your username and
|
|||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
Main View with queue and cover art
|
Player
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Song Queue
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Artists Page
|
Artists Page
|
||||||
|
|
||||||
@@ -31,4 +35,8 @@ Artists Page
|
|||||||
|
|
||||||
View of favorite songs
|
View of favorite songs
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
View of some random albums
|
||||||
|
|
||||||
|

|
||||||
+127
-30
@@ -32,30 +32,30 @@ class Album:
|
|||||||
name: str
|
name: str
|
||||||
album: str
|
album: str
|
||||||
artist: str
|
artist: str
|
||||||
coverArtId: str
|
coverArtId: str | None
|
||||||
duration: int
|
duration: int
|
||||||
artistId: str
|
artistId: str
|
||||||
songCount: int
|
songCount: int
|
||||||
playCount: int
|
playCount: int
|
||||||
year: int | None
|
year: int | None
|
||||||
duration: str
|
duration_f: str
|
||||||
|
|
||||||
def __init__(self, album_info: dict):
|
def __init__(self, album_info: dict):
|
||||||
self.id = album_info['id']
|
self.id = album_info['id']
|
||||||
self.title = album_info['title']
|
|
||||||
self.name = album_info['name']
|
self.name = album_info['name']
|
||||||
self.album = album_info['album']
|
self.title = album_info['title'] if 'title' in album_info else self.name
|
||||||
self.artist = album_info['artist']
|
self.album = album_info['album'] if 'album' in album_info else self.name
|
||||||
self.coverArtId = album_info['coverArt']
|
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.duration = int(album_info['duration'])
|
||||||
self.artistId = album_info['artistId']
|
self.artistId = album_info.get('albumArtistId')
|
||||||
self.songCount = int(album_info['songCount'])
|
self.songCount = int(album_info['songCount'])
|
||||||
self.playCount = int(album_info['playCount']) if 'playCount' in album_info else 0
|
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.year = int(album_info['year']) if 'year' in album_info else None
|
||||||
self.duration = self._secondToMinutes(int(album_info['duration']))
|
self.duration_f = self.duration = time.strftime('%H:%M:%S', time.gmtime(int(album_info['duration'])))
|
||||||
|
|
||||||
def _secondToMinutes(self, s: int) -> str:
|
|
||||||
return f'{s // 60}:{s % 60 if s % 60 > 9 else "0" + str(s % 60)}'
|
|
||||||
|
|
||||||
class Song:
|
class Song:
|
||||||
id: str
|
id: str
|
||||||
@@ -76,16 +76,20 @@ class Song:
|
|||||||
samplingRate: int
|
samplingRate: int
|
||||||
playCount: int | None
|
playCount: int | None
|
||||||
coverArtId: str | None
|
coverArtId: str | None
|
||||||
|
# Navidrome Fields
|
||||||
|
rating: int | None
|
||||||
|
|
||||||
def __init__(self, info: dict | None = {}):
|
def __init__(self, info: dict | None = None):
|
||||||
|
if info is None:
|
||||||
|
info = {}
|
||||||
self.id = info['id'] if 'id' in info else ""
|
self.id = info['id'] if 'id' in info else ""
|
||||||
self.title = info['title'] if 'title' in info else ""
|
self.title = info['title'] if 'title' in info else ""
|
||||||
self.album = info['album'] if 'album' in info else ""
|
self.album = info['album'] if 'album' in info else ""
|
||||||
self.artist = info['artist'] if 'artist' in info else ""
|
self.artist = info['artist'] if 'artist' in info else ""
|
||||||
self.trackNumber = int(info['track']) if 'track' in info else ""
|
self.trackNumber = int(info['track']) if 'track' in info else 0
|
||||||
self.contentType = info['contentType'] if 'contentType' in info else ""
|
self.contentType = info['contentType'] if 'contentType' in info else ""
|
||||||
self.suffix = info['suffix'] if 'suffix' in info else ""
|
self.suffix = info['suffix'] if 'suffix' in info else ""
|
||||||
self.duration = int(info['duration']) if 'duration' 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.bitRate = int(info['bitRate']) if 'bitRate' in info else ""
|
||||||
self.path = info['path'] if 'path' in info else ""
|
self.path = info['path'] if 'path' in info else ""
|
||||||
self.discNumber = int(info['discNumber']) if 'discNumber' in info else ""
|
self.discNumber = int(info['discNumber']) if 'discNumber' in info else ""
|
||||||
@@ -96,6 +100,7 @@ class Song:
|
|||||||
self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else ""
|
self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else ""
|
||||||
self.playCount = int(info['playCount']) if 'playCount' in info else ""
|
self.playCount = int(info['playCount']) if 'playCount' in info else ""
|
||||||
self.coverArtId = info['coverArt'] if 'coverArt' in info else None
|
self.coverArtId = info['coverArt'] if 'coverArt' in info else None
|
||||||
|
self.rating = info.get('rating')
|
||||||
|
|
||||||
class Artist:
|
class Artist:
|
||||||
id: str
|
id: str
|
||||||
@@ -130,34 +135,30 @@ class SubpyConn:
|
|||||||
salt: str
|
salt: str
|
||||||
salt_len: int = 8
|
salt_len: int = 8
|
||||||
version: str = "1.16.1"
|
version: str = "1.16.1"
|
||||||
client_name: str = "testing"
|
client_name: str = "SubSonic Python API Lib"
|
||||||
|
|
||||||
def __init__(self, u: str, p: str, base_url: str, salt_len: int | None = 8):
|
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.username = u
|
||||||
self.password = p
|
self.password = p
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
|
self.client_name = client_name
|
||||||
|
|
||||||
self.salt_len = salt_len
|
self.salt_len = salt_len
|
||||||
|
|
||||||
def _saltGenerator(self) -> None:
|
|
||||||
self.salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len))
|
|
||||||
|
|
||||||
def _hashPass(self) -> str:
|
|
||||||
self._saltGenerator()
|
|
||||||
return hashlib.md5((self.password + self.salt).encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
def _getBasePayload(self) -> dict:
|
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 {
|
return {
|
||||||
"u" : self.username,
|
"u": self.username,
|
||||||
"t" : self._hashPass(),
|
"t": token,
|
||||||
"s" : self.salt,
|
"s": salt,
|
||||||
"v" : self.version,
|
"v": self.version,
|
||||||
"c" : self.client_name,
|
"c": self.client_name,
|
||||||
"f": "json"
|
"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, doseq=True)
|
||||||
|
|
||||||
def _getMusicDirectory(self, id: str):
|
def _getMusicDirectory(self, id: str):
|
||||||
url = self.base_url + '/rest/getMusicDirectory?'
|
url = self.base_url + '/rest/getMusicDirectory?'
|
||||||
@@ -265,6 +266,9 @@ class SubpyConn:
|
|||||||
|
|
||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
|
|
||||||
|
if 'entry' not in json_r['subsonic-response']['playlist']:
|
||||||
|
return []
|
||||||
|
|
||||||
songs: list[Song] = []
|
songs: list[Song] = []
|
||||||
for song in json_r['subsonic-response']['playlist']['entry']:
|
for song in json_r['subsonic-response']['playlist']['entry']:
|
||||||
songs.append(Song(song))
|
songs.append(Song(song))
|
||||||
@@ -280,7 +284,7 @@ class SubpyConn:
|
|||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
|
|
||||||
starred: list[Album | Song | Artist] = []
|
starred: list[Album | Song | Artist] = []
|
||||||
for album in json_r['subsonic-response']['starred']['artist']:
|
for album in json_r['subsonic-response']['starred']['album']:
|
||||||
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']:
|
||||||
@@ -306,7 +310,7 @@ class SubpyConn:
|
|||||||
return starred
|
return starred
|
||||||
|
|
||||||
# Returns a requests Response object, the data stream can be found in Response.content
|
# Returns a requests Response object, the data stream can be found in Response.content
|
||||||
def stream(self, song_id: str, maxBitRate: int | None = None) -> requests.Response:
|
def stream(self, song_id: str, maxBitRate: int | None = None, format: str | None = None) -> requests.Response:
|
||||||
url = self.base_url + '/rest/stream?'
|
url = self.base_url + '/rest/stream?'
|
||||||
|
|
||||||
payload = self._getBasePayload()
|
payload = self._getBasePayload()
|
||||||
@@ -314,6 +318,9 @@ class SubpyConn:
|
|||||||
if maxBitRate:
|
if maxBitRate:
|
||||||
payload['maxBitRate'] = maxBitRate
|
payload['maxBitRate'] = maxBitRate
|
||||||
|
|
||||||
|
if format:
|
||||||
|
payload['format'] = format
|
||||||
|
|
||||||
payload['id'] = song_id
|
payload['id'] = song_id
|
||||||
|
|
||||||
return requests.get(url + self._encodePayload(payload), stream=True)
|
return requests.get(url + self._encodePayload(payload), stream=True)
|
||||||
@@ -381,6 +388,21 @@ class SubpyConn:
|
|||||||
if response['status'] != 'ok':
|
if response['status'] != 'ok':
|
||||||
raise BadSubsonicResponse()
|
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/
|
https://opensubsonic.netlify.app/docs/endpoints/scrobble/
|
||||||
|
|
||||||
@@ -448,5 +470,80 @@ class SubpyConn:
|
|||||||
|
|
||||||
return random_songs
|
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']
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from subpy!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -87,3 +87,214 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Player Screen
|
||||||
|
|
||||||
|
#playerTrackTitleBox {
|
||||||
|
padding: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playerArtc {
|
||||||
|
align: center middle;
|
||||||
|
# width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerTrackinfo {
|
||||||
|
# text-align: center;
|
||||||
|
# content-align: center middle;
|
||||||
|
height: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerArt {
|
||||||
|
content-align: center middle;
|
||||||
|
height: 1fr;
|
||||||
|
width: 0.5fr;
|
||||||
|
padding: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progressBarWrapper {
|
||||||
|
height: 3;
|
||||||
|
width: 0.75fr;
|
||||||
|
padding: 1 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerProgressBarTime {
|
||||||
|
width: 0.1fr;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerProgressBarTotalTime {
|
||||||
|
width: 0.1fr;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#percentage {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eta {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerProgressBar {
|
||||||
|
height: 3;
|
||||||
|
width: 0.75fr;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerProgressBar #bar {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playerButtons {
|
||||||
|
height: 0.25fr;
|
||||||
|
padding-bottom: 1;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerButton {
|
||||||
|
height: 5;
|
||||||
|
max-width: 12;
|
||||||
|
max-height: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#queueModal {
|
||||||
|
height: 75%;
|
||||||
|
width: 75%;
|
||||||
|
border: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal Container {
|
||||||
|
height: 75%;
|
||||||
|
width: 75%;
|
||||||
|
border: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal Label {
|
||||||
|
text-style: bold;
|
||||||
|
color: #0073e6;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#PSbutton {
|
||||||
|
width: 100%;
|
||||||
|
align: right middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm Button {
|
||||||
|
background: #0080ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm Label {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playlistFormModal {
|
||||||
|
height: 30%;
|
||||||
|
width: 70%;
|
||||||
|
border: grey;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#songModeSelectorList {
|
||||||
|
height: 100%;
|
||||||
|
width: 0.2fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#songModeSelectorList Label {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#songDatatable {
|
||||||
|
margin: 1 0 0 1;
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#albumModeSelectorList {
|
||||||
|
height: 100%;
|
||||||
|
width: 0.2fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#albumModeSelectorList Label {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#albumSearchBar {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#albumList {
|
||||||
|
min-height: 1fr;
|
||||||
|
height: 1fr;
|
||||||
|
# border: white;
|
||||||
|
margin: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
SongTable Input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal Horizontal {
|
||||||
|
min-width: 30%;
|
||||||
|
min-height: 3;
|
||||||
|
max-height: 5;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal Label {
|
||||||
|
content-align: center middle;
|
||||||
|
padding: 1 2 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Vertical {
|
||||||
|
max-width: 50%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 2;
|
||||||
|
border: blue;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Label {
|
||||||
|
content-align: center middle;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Horizontal {
|
||||||
|
align: center middle;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal RadioSet {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Button {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[project]
|
||||||
|
name = "subpy"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = "<3.14,>=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"pillow>=12.0.0",
|
||||||
|
"pygame>=2.6.1",
|
||||||
|
"pynput>=1.8.1",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
"rich-pixels>=3.0.1",
|
||||||
|
"textual>=6.5.0",
|
||||||
|
]
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Planned Features
|
||||||
|
|
||||||
|
- Add a songs tab (should replace F4 Favourites, favourite songs will still be there)
|
||||||
|
- A view of all songs should be a paged datatable
|
||||||
|
- Shuffle artist (shuffle play all the songs by an artist)
|
||||||
|
- better playlist UI
|
||||||
|
- Better albums UI
|
||||||
|
- Config for no fancy images UI
|
||||||
|
- Auto cache deletion (if not played in x number of days remove)
|
||||||
|
- Accurate play time tracking server (optionally) linked to player
|
||||||
|
- Create / Edit playlist features
|
||||||
|
|
||||||
|
# Features
|
||||||
|
- Playlist Management
|
||||||
|
- [x] Create Playlist
|
||||||
|
- [x] Add songs
|
||||||
|
- This is only really half done, more screens need this feature, but I'm moving this to a tech debt state
|
||||||
|
- [ ] Remove songs
|
||||||
|
- [ ] Change order
|
||||||
|
- [ ] Rename
|
||||||
|
- [ ] Set public / private
|
||||||
|
|
||||||
|
# Technical Features
|
||||||
|
- [ ] Custom song table widget that's an extension of DataTable. There are some universal functions / keybinds a user should be able to use when looking at a list of songs, ex:
|
||||||
|
- [x] Add to playlist
|
||||||
|
- [x] Favorite
|
||||||
|
- [x] Queue
|
||||||
|
- [x] Play
|
||||||
|
- [x] Share
|
||||||
|
- [x] Rate
|
||||||
|
- [ ] Go to album
|
||||||
|
- [ ] Go to artist
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.13.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.10.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "evdev"
|
||||||
|
version = "1.9.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", hash = "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069", size = 33301, upload-time = "2025-05-01T19:53:47.69Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkify-it-py"
|
||||||
|
version = "2.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "uc-micro-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
linkify = [
|
||||||
|
{ name = "linkify-it-py" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdit-py-plugins"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygame"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pynput"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "evdev", marker = "'linux' in sys_platform" },
|
||||||
|
{ name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "python-xlib", marker = "'linux' in sys_platform" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-core"
|
||||||
|
version = "12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ab/dc/6d63019133e39e2b299dfbab786e64997fff0f145c45a417e1dd51faaf3f/pyobjc_core-12.0.tar.gz", hash = "sha256:7e05c805a776149a937b61b892a0459895d32d9002bedc95ce2be31ef1e37a29", size = 991669, upload-time = "2025-10-21T08:26:07.496Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/17/6c247bf9d8de2813f6015671f242333534797e81bdac9e85516fb57dfb00/pyobjc_core-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c44b76d8306a130c9eb0cb79d86fd6675c8ba3e5b458e78095d271a10cd38b6a", size = 679700, upload-time = "2025-10-21T07:51:09.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/a3/1b26c438c78821e5a82b9c02f7b19a86097aeb2c51132d06e159acc22dc2/pyobjc_core-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c617551e0ab860c49229fcec0135a5cde702485f22254ddc17205eb24b7fc55", size = 721370, upload-time = "2025-10-21T07:51:55.981Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-applicationservices"
|
||||||
|
version = "12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
{ name = "pyobjc-framework-coretext" },
|
||||||
|
{ name = "pyobjc-framework-quartz" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/79/0b7a00bcc7561c816281382c933a46aa7a90acca48b942054b7d32d0caf7/pyobjc_framework_applicationservices-12.0.tar.gz", hash = "sha256:eabbf6c57573158714aa656e5d0112330a87692db336aae7e94e216db89e93be", size = 103595, upload-time = "2025-10-21T08:26:32.651Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/cf/ae603c46217c04ec7598c62a2d46fa9b6ab66e127148bff1f352b850fc72/pyobjc_framework_applicationservices-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9ff39c0301f2430253fbfea114afb00594426e0b66a1bec1c28cd60f75d02005", size = 32871, upload-time = "2025-10-21T07:54:15.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/79/a578c8b1aa8634c2c9f8bbd66a3cdc385013a4cd9558741a4da26c040e51/pyobjc_framework_applicationservices-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ecd7651ab330790722f3590465392dbab3d76be0370ff7e015584053d571e218", size = 33132, upload-time = "2025-10-21T07:54:18.388Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-cocoa"
|
||||||
|
version = "12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/29/cfef5f021576976698c6ae195fa304238b9f6716e1b3eb11258d2572afe9/pyobjc_framework_cocoa-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:13e573f5093f4158f305b1bac5e1f783881ce2f5f4a69f3c80cb000f76731259", size = 384659, upload-time = "2025-10-21T07:59:34.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/37/d2d9a143ab5387815a00f478916a52425c4792678366ef6cedf20b8cc9cd/pyobjc_framework_cocoa-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3b167793cd1b509eaf693140ace9be1f827a2c8686fceb8c599907661f608bc2", size = 388787, upload-time = "2025-10-21T08:00:00.006Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-coretext"
|
||||||
|
version = "12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
{ name = "pyobjc-framework-quartz" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/36/32ec183e555b73152d7813f6f7c277fd018440f70a1f142bd75b04946089/pyobjc_framework_coretext-12.0.tar.gz", hash = "sha256:8cc0c7dd2b7e68ad1c760784e422722550c77cbdbd60eb455170ec444ca1cfd2", size = 90546, upload-time = "2025-10-21T08:32:31.291Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/64/31da2b1236c710b963510fc03008ebe607d03e2c0288467db9bf9f297873/pyobjc_framework_coretext-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecd424cf6da1a69cad40ef4007bc5af842ccb7456c5fcc4c9aded40e3e0c22ba", size = 30119, upload-time = "2025-10-21T08:04:57.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/1d/d23fa47ffb6ad32e26a58e357619b5564b4f6e421a839d12961cce521c8f/pyobjc_framework_coretext-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:60e84e46e0aeb12101a4354c39ce84066107773b0c96fdc4ff15fd1662dc88d8", size = 30702, upload-time = "2025-10-21T08:05:00.387Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyobjc-framework-quartz"
|
||||||
|
version = "12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-core" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/0b/3c34fc9de790daff5ca49d1f36cb8dcc353ac10e4e29b4759e397a3831f4/pyobjc_framework_quartz-12.0.tar.gz", hash = "sha256:5bcb9e78d671447e04d89e2e3c39f3135157892243facc5f8468aa333e40d67f", size = 3159509, upload-time = "2025-10-21T08:40:01.918Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d8/05f8fb5f27af69c0b5a9802f220a7c00bbe595c790e13edefa042603b957/pyobjc_framework_quartz-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ece7a05aa2bfc3aa215f1a7c8580e873f3867ba40d0006469618cc2ceb796578", size = 219201, upload-time = "2025-10-21T08:17:59.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/3f/1228f86de266874e20c04f04736a5f11c5a29a1839efde594ba4097d0255/pyobjc_framework_quartz-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f1b2e34f6f0dd023f80a0e875af4dab0ad27fccac239da9ad3d311a2d2578e27", size = 224330, upload-time = "2025-10-21T08:18:14.776Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-xlib"
|
||||||
|
version = "0.33"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich-pixels"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "rich" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/31/71/6d5cd4b8d67cd49366eda19aaf37f20094ce562223a91166109202590237/rich_pixels-3.0.1.tar.gz", hash = "sha256:4a81977d45437ce5009cdcaf70af80256c3bdfab870e87ab802c577ba4133235", size = 24631, upload-time = "2024-03-30T09:37:52.834Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/72/7264494bc0944db1166b73c88f19d9ddfc584dbbc77c210cd0f52f59c511/rich_pixels-3.0.1-py3-none-any.whl", hash = "sha256:e82c5aa0d00885609675494f16e1ef814c68fa795634f1d6917cae9159b755e1", size = 6004, upload-time = "2024-03-30T09:37:51.169Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subpy"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pygame" },
|
||||||
|
{ name = "pynput" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "rich-pixels" },
|
||||||
|
{ name = "textual" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "pillow", specifier = ">=12.0.0" },
|
||||||
|
{ name = "pygame", specifier = ">=2.6.1" },
|
||||||
|
{ name = "pynput", specifier = ">=1.8.1" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
{ name = "rich-pixels", specifier = ">=3.0.1" },
|
||||||
|
{ name = "textual", specifier = ">=6.5.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textual"
|
||||||
|
version = "6.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/90/59757aa887ddcea61428820274f1a2d1f986feb7880374a5420ab5d37132/textual-6.5.0.tar.gz", hash = "sha256:e5f152cdd47db48a635d23b839721bae4d0e8b6d855e3fede7285218289294e3", size = 1574116, upload-time = "2025-10-31T17:21:53.4Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/37/1deba011782a49ea249c73adcf703a39b0249ac9b0e17d1a2e4074df8d57/textual-6.5.0-py3-none-any.whl", hash = "sha256:c5505be7fe606b8054fb88431279885f88352bddca64832f6acd293ef7d9b54f", size = 711848, upload-time = "2025-10-31T17:21:51.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uc-micro-py"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||||
|
]
|
||||||
+332
@@ -0,0 +1,332 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pyperclip
|
||||||
|
|
||||||
|
from textual import on
|
||||||
|
from textual.widgets import DataTable, Input, Label, Button, RadioSet, RadioButton
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
|
|
||||||
|
import SubSonic
|
||||||
|
|
||||||
|
def fix_duration(seconds: int) -> str:
|
||||||
|
min, sec = divmod(seconds, 60)
|
||||||
|
|
||||||
|
min = max(0, min)
|
||||||
|
sec = max(0, sec)
|
||||||
|
|
||||||
|
formatted = f"{min}:{sec:02d}"
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
class RatingModal(ModalScreen[int]):
|
||||||
|
"""Modal for getting a numerical rating for an Album, Artist, or Song"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Close')
|
||||||
|
]
|
||||||
|
|
||||||
|
thing_title: str
|
||||||
|
selected_rating: int = 0
|
||||||
|
|
||||||
|
def __init__(self, thing_title: str):
|
||||||
|
self.thing_title = thing_title
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
label: Label = self.query_one(Label)
|
||||||
|
label.update(f'Select a rating for {self.thing_title}')
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical():
|
||||||
|
yield Label()
|
||||||
|
with Horizontal():
|
||||||
|
with RadioSet():
|
||||||
|
yield RadioButton("0", value = True)
|
||||||
|
yield RadioButton("1")
|
||||||
|
yield RadioButton("2")
|
||||||
|
yield RadioButton("3")
|
||||||
|
yield RadioButton("4")
|
||||||
|
yield RadioButton("5")
|
||||||
|
with Horizontal():
|
||||||
|
yield Button("Submit")
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def selection_submit(self, event: Button.Pressed) -> None:
|
||||||
|
self.dismiss(self.selected_rating)
|
||||||
|
|
||||||
|
@on(RadioSet.Changed)
|
||||||
|
def selection_changed(self, event: RadioSet.Changed) -> None:
|
||||||
|
self.selected_rating = event.radio_set.pressed_index
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
class ShareLinkModal(ModalScreen):
|
||||||
|
"""Modal for copying a generated share link"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Close')
|
||||||
|
]
|
||||||
|
|
||||||
|
link: str
|
||||||
|
|
||||||
|
def __init__(self, link: str):
|
||||||
|
self.link = link
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
label: Label = self.query_one(Label)
|
||||||
|
label.update(self.link)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
yield Label()
|
||||||
|
yield Button("Copy Link")
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def copy_link(self) -> None:
|
||||||
|
pyperclip.copy(self.link)
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
class PlaylistSelectionModal(ModalScreen[str]):
|
||||||
|
"""Modal for selecting a playlist"""
|
||||||
|
|
||||||
|
playlists: list[SubSonic.Playlist] = []
|
||||||
|
selected_playlist_id: str = ""
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Back')
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container():
|
||||||
|
yield Label("Select a playlist")
|
||||||
|
yield DataTable()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.playlists = self.app.conn.getPlaylists()
|
||||||
|
playlistTable = self.query_one(DataTable)
|
||||||
|
|
||||||
|
playlistTable.cursor_type = "row"
|
||||||
|
playlistTable.add_columns("Name", "Songs", "Time", "Owner")
|
||||||
|
|
||||||
|
for playlist in self.playlists:
|
||||||
|
playlistTable.add_row(
|
||||||
|
playlist.name,
|
||||||
|
playlist.songCount,
|
||||||
|
playlist.duration,
|
||||||
|
playlist.owner,
|
||||||
|
key=playlist.id
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(DataTable.RowHighlighted)
|
||||||
|
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
self.selected_playlist_id = event.row_key.value
|
||||||
|
|
||||||
|
@on(DataTable.RowSelected)
|
||||||
|
def playlist_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
self.dismiss(self.selected_playlist_id)
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss("")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SongTableColumnConfig:
|
||||||
|
track_number: bool = False
|
||||||
|
title: bool = True
|
||||||
|
album: bool = True
|
||||||
|
artist: bool = True
|
||||||
|
plays: bool = True
|
||||||
|
duration: bool = True
|
||||||
|
rating: bool = False
|
||||||
|
bitrate: bool = False
|
||||||
|
|
||||||
|
# Maps config field -> (column header, song attribute)
|
||||||
|
COLUMN_MAPPING: list[tuple[str, str, str]] = [
|
||||||
|
('track_number', '#', 'trackNumber'),
|
||||||
|
('title', 'Title', 'title'),
|
||||||
|
('album', 'Album', 'album'),
|
||||||
|
('artist', 'Artist', 'artist'),
|
||||||
|
('plays', 'Plays', 'playCount'),
|
||||||
|
('duration', 'Duration', 'duration'),
|
||||||
|
('rating', 'Rating', 'rating'),
|
||||||
|
('bitrate', 'Bitrate', 'bitRate')
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
A custom DataTable designed to hold and display songs
|
||||||
|
|
||||||
|
TODO @0x01fe : needs optional paging on init
|
||||||
|
"""
|
||||||
|
class SongTable(Widget):
|
||||||
|
BINDINGS = [
|
||||||
|
('p', 'play()', 'Play Song'),
|
||||||
|
('q', 'queue()', 'Queue Song'),
|
||||||
|
('m', 'shuffle()', 'Shuffle Play'),
|
||||||
|
('A', 'add_to_playlist()', 'Add to Playlist'),
|
||||||
|
('f', 'star_song()', 'Star Song'),
|
||||||
|
('S', 'share_song()', 'Share Song'),
|
||||||
|
('R', 'rate_song()', 'Rate Song'),
|
||||||
|
Binding('/', 'filter()', 'Search', show=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
columns: SongTableColumnConfig
|
||||||
|
songs: list[SubSonic.Song] = []
|
||||||
|
filter_active: bool = False
|
||||||
|
selected_song_id: str | None = None
|
||||||
|
max_col_width: int | None = None
|
||||||
|
|
||||||
|
OVERFLOW_INDICATOR: str = "..."
|
||||||
|
|
||||||
|
def __init__(self, columns: SongTableColumnConfig | None = SongTableColumnConfig(), songs: list[SubSonic.Song] | None = [], max_col_width: int | None = None, **kwargs):
|
||||||
|
self.columns = columns
|
||||||
|
self.songs = songs
|
||||||
|
self.max_col_width = max_col_width
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Input(placeholder="Search", id="filter")
|
||||||
|
yield DataTable()
|
||||||
|
|
||||||
|
def _format_cell(self, config_attr: str, value):
|
||||||
|
"""Format cell values for display in the DataTable."""
|
||||||
|
if config_attr == "duration":
|
||||||
|
try:
|
||||||
|
return fix_duration(int(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fix_duration(0)
|
||||||
|
if self.max_col_width and type(value) == str and len(value) > self.max_col_width:
|
||||||
|
value = value[:self.max_col_width - len(self.OVERFLOW_INDICATOR)]
|
||||||
|
value += self.OVERFLOW_INDICATOR
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _row_data_for_song(self, song: SubSonic.Song) -> list:
|
||||||
|
"""Build DataTable row values based on configured columns."""
|
||||||
|
row_data = []
|
||||||
|
for config_attr, _, song_attr in COLUMN_MAPPING:
|
||||||
|
if getattr(self.columns, config_attr):
|
||||||
|
value = getattr(song, song_attr)
|
||||||
|
row_data.append(self._format_cell(config_attr, value))
|
||||||
|
return row_data
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.focus()
|
||||||
|
dt.cursor_type = "row"
|
||||||
|
|
||||||
|
# Add columns based on config
|
||||||
|
for config_attr, header, _ in COLUMN_MAPPING:
|
||||||
|
if getattr(self.columns, config_attr):
|
||||||
|
dt.add_column(header)
|
||||||
|
|
||||||
|
# Add rows for existing songs
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(i))
|
||||||
|
|
||||||
|
def add_song(self, song: SubSonic.Song) -> None:
|
||||||
|
self.songs.append(song)
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(len(self.songs) - 1))
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def search_submit(self, event: Input.Submitted) -> None:
|
||||||
|
search_value = event.value.lower()
|
||||||
|
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.clear()
|
||||||
|
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
if (
|
||||||
|
re.search(search_value, song.title.lower())
|
||||||
|
or re.search(search_value, song.artist.lower())
|
||||||
|
or re.search(search_value, song.album.lower())
|
||||||
|
):
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(i))
|
||||||
|
|
||||||
|
@on(DataTable.RowHighlighted)
|
||||||
|
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
if not self.songs:
|
||||||
|
return
|
||||||
|
self.selected_song_id = self.songs[int(event.row_key.value)].id
|
||||||
|
|
||||||
|
def action_play(self) -> None:
|
||||||
|
song_index: int = self._find_selected_song_index()
|
||||||
|
self.app.play_song(self.songs[song_index])
|
||||||
|
self.app.song_queue = self.songs[song_index + 1:]
|
||||||
|
|
||||||
|
def action_queue(self) -> None:
|
||||||
|
song_index: int = self._find_selected_song_index()
|
||||||
|
self.app.queue_song(self.songs[song_index])
|
||||||
|
|
||||||
|
def action_shuffle(self) -> None:
|
||||||
|
q: list[SubSonic.Song] = self.songs.copy()
|
||||||
|
random.shuffle(q)
|
||||||
|
self.app.play_song(q.pop(0))
|
||||||
|
self.app.song_queue = q
|
||||||
|
|
||||||
|
def action_add_to_playlist(self) -> None:
|
||||||
|
|
||||||
|
def process_modal_selection(playlist_id: str) -> None:
|
||||||
|
self.app.conn.updatePlaylist(playlist_id, songIdToAdd=[self.selected_song_id])
|
||||||
|
self.app.notify("Added song to playlist!")
|
||||||
|
|
||||||
|
self.app.push_screen(PlaylistSelectionModal(), process_modal_selection)
|
||||||
|
|
||||||
|
def action_star_song(self) -> None:
|
||||||
|
song = self.songs[self._find_selected_song_index()]
|
||||||
|
|
||||||
|
is_song_starred: bool = self.app.navidrome.isSongStarred(song.id)
|
||||||
|
|
||||||
|
if not is_song_starred:
|
||||||
|
asyncio.create_task(asyncio.to_thread(self.app.conn.star, self.selected_song_id))
|
||||||
|
self.app.notify(f'Starred song "{song.title}"')
|
||||||
|
else:
|
||||||
|
asyncio.create_task(asyncio.to_thread(self.app.conn.unstar, self.selected_song_id))
|
||||||
|
self.app.notify(f'Unstarred song "{song.title}"')
|
||||||
|
|
||||||
|
def action_share_song(self) -> None:
|
||||||
|
share_link: str = self.app.conn.createShare(self.selected_song_id)
|
||||||
|
self.app.push_screen(ShareLinkModal(share_link))
|
||||||
|
|
||||||
|
def action_rate_song(self) -> None:
|
||||||
|
song: SubSonic.Song = self.songs[self._find_selected_song_index()]
|
||||||
|
|
||||||
|
def process_modal_selection(rating: int | None) -> None:
|
||||||
|
if rating == None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.app.conn.setRating(self.selected_song_id, rating)
|
||||||
|
if rating != 0:
|
||||||
|
self.app.notify(f'Rated "{song.title}" a {rating}.')
|
||||||
|
else:
|
||||||
|
self.app.notify(f'Removed rating for "{song.title}".')
|
||||||
|
|
||||||
|
self.app.push_screen(RatingModal(f'"{song.title}" By {song.artist}'), process_modal_selection)
|
||||||
|
|
||||||
|
# Just makes the filter appear
|
||||||
|
def action_filter(self) -> None:
|
||||||
|
search_bar: Input = self.query_one(Input)
|
||||||
|
|
||||||
|
if not self.filter_active:
|
||||||
|
search_bar.display = 'block'
|
||||||
|
else:
|
||||||
|
search_bar.display = 'none'
|
||||||
|
|
||||||
|
self.filter_active = not self.filter_active
|
||||||
|
search_bar.focus()
|
||||||
|
|
||||||
|
# Returns index of selected song
|
||||||
|
def _find_selected_song_index(self) -> int:
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
if song.id == self.selected_song_id:
|
||||||
|
return i
|
||||||
Reference in new issue
Block a user