Compare commits

..
16 Commits
Author SHA1 Message Date
0x01FEandJackson Hodge 2295af7b56 New SongView, UV & More (#6)
Honestly, I don't remember everything I've done in this branch.

Important things:
 - Started using UV for dependency / python version management
 - Fixed the stupid queue skipping bug (still can't believe it was a race condition)
 - New SongView widget that multiple screens share
 - New features on most views with songs!
   - Songs can be shared
   - Songs can be added to playlists
   - Songs can be rated
 - Upgraded my Navidrome server version & had to change some stuff for it

Co-authored-by: Jackson Hodge <jackson.d.hodge@jbhunt.com>
Reviewed-on: #6
2026-03-04 11:30:52 -06:00
0x01FE d3de6ee914 added some uv files for package management 2025-11-09 14:15:19 -06:00
0x01FE 0ca2547b54 New Buttons (#5)
Added two new buttons to the player:
 - Favorite song button
 - Queue mode button

Reviewed-on: #5
2025-11-05 13:16:51 -06:00
0x01FE 4c6976b2df Album Screen Redesign (#4)
Album screen redo

Reviewed-on: #4
2025-11-03 14:07:50 -06:00
0x01FE 9c462679c8 Merge pull request 'Songs Screen UI Redo' (#3) from songs-screen-redo into main
Reviewed-on: #3
2025-10-22 11:28:57 -05:00
JISAUAY a213e971e6 Updated screenshots in readme 2025-10-22 11:24:59 -05:00
JISAUAY 4d413a24a3 fix screen subtitles 2025-10-22 11:22:57 -05:00
JISAUAY 48c9aca87e updated songs screen 2025-10-22 11:13:12 -05:00
JISAUAY 1e4616d498 started new songs screen 2025-10-08 12:49:19 -05:00
0x01FE a2e53f1bc7 Merge pull request 'Main player UI redo' (#2) from player-ui-redo into main
Reviewed-on: #2
2025-10-08 10:27:44 -05:00
JISAUAY 4aba71cf2c updated readme 2025-10-08 10:25:20 -05:00
JISAUAY 26c9612648 added a roadmap 2025-10-08 10:19:33 -05:00
JISAUAY d601d11e4b fixed CurrentPlace being bellow 1 2025-10-03 12:06:06 -05:00
JISAUAY 3e07ef0806 removed old player stuff 2025-10-03 10:17:54 -05:00
JISAUAY 7bb50b64c7 added queue modal screen to player 2025-10-03 10:08:49 -05:00
JISAUAY ada1ef5fba New UI for player screen 2025-10-02 16:20:20 -05:00
17 changed files with 1780 additions and 263 deletions

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
View File
@@ -1,6 +1,9 @@
__pycache__
__pycache__/
.cache/
examples/
.venv/
*.log
auth
test.py
+1
View File
@@ -0,0 +1 @@
3.13
+118
View File
@@ -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']
+12 -4
View File
@@ -1,6 +1,6 @@
# 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
@@ -18,9 +18,13 @@ This program requires a file simply named `auth` that contains your username and
# Screenshots
Main View with queue and cover art
Player
![Main music player view](.attachments/player.png)
![Music player with media control buttons, a progress bar, and cover art](.attachments/player.png)
Song Queue
![Modal screen infront of the player with a table holding songs in the queue](.attachments/queue.png)
Artists Page
@@ -31,4 +35,8 @@ Artists Page
View of favorite songs
![View of favorited songs](.attachments/favorite-songs.png)
![View of favorited songs](.attachments/songs-screen-favourites.png)
View of some random albums
![View of some random albums](.attachments/albums-screen-random.png)
+127 -30
View File
@@ -32,30 +32,30 @@ class Album:
name: str
album: str
artist: str
coverArtId: str
coverArtId: str | None
duration: int
artistId: str
songCount: int
playCount: int
year: int | None
duration: str
duration_f: str
def __init__(self, album_info: dict):
self.id = album_info['id']
self.title = album_info['title']
self.name = album_info['name']
self.album = album_info['album']
self.artist = album_info['artist']
self.coverArtId = album_info['coverArt']
self.title = album_info['title'] if 'title' in album_info else self.name
self.album = album_info['album'] if 'album' in album_info else self.name
self.artist = album_info.get('artist')
if not self.artist:
self.artist = album_info.get('albumArtist')
self.coverArtId = album_info['coverArt'] if 'coverArt' in album_info else None
self.duration = int(album_info['duration'])
self.artistId = album_info['artistId']
self.artistId = album_info.get('albumArtistId')
self.songCount = int(album_info['songCount'])
self.playCount = int(album_info['playCount']) if 'playCount' in album_info else 0
self.year = int(album_info['year']) if 'year' in album_info else None
self.duration = 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:
id: str
@@ -76,16 +76,20 @@ class Song:
samplingRate: int
playCount: int | 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.title = info['title'] if 'title' in info else ""
self.album = info['album'] if 'album' in info else ""
self.artist = info['artist'] if 'artist' in info else ""
self.trackNumber = int(info['track']) if 'track' in info else ""
self.trackNumber = int(info['track']) if 'track' in info else 0
self.contentType = info['contentType'] if 'contentType' in info else ""
self.suffix = info['suffix'] if 'suffix' in info else ""
self.duration = int(info['duration']) if 'duration' in info else ""
self.duration = int(info['duration']) if 'duration' in info else 0
self.bitRate = int(info['bitRate']) if 'bitRate' in info else ""
self.path = info['path'] if 'path' in info else ""
self.discNumber = int(info['discNumber']) if 'discNumber' in info else ""
@@ -96,6 +100,7 @@ class Song:
self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else ""
self.playCount = int(info['playCount']) if 'playCount' in info else ""
self.coverArtId = info['coverArt'] if 'coverArt' in info else None
self.rating = info.get('rating')
class Artist:
id: str
@@ -130,34 +135,30 @@ class SubpyConn:
salt: str
salt_len: int = 8
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.password = p
self.base_url = base_url
self.client_name = client_name
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:
salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len))
token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest()
return {
"u" : self.username,
"t" : self._hashPass(),
"s" : self.salt,
"v" : self.version,
"c" : self.client_name,
"u": self.username,
"t": token,
"s": salt,
"v": self.version,
"c": self.client_name,
"f": "json"
}
def _encodePayload(self, payload: dict) -> str:
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus)
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus, doseq=True)
def _getMusicDirectory(self, id: str):
url = self.base_url + '/rest/getMusicDirectory?'
@@ -265,6 +266,9 @@ class SubpyConn:
json_r = r.json()
if 'entry' not in json_r['subsonic-response']['playlist']:
return []
songs: list[Song] = []
for song in json_r['subsonic-response']['playlist']['entry']:
songs.append(Song(song))
@@ -280,7 +284,7 @@ class SubpyConn:
json_r = r.json()
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))
for artist in json_r['subsonic-response']['starred']['artist']:
@@ -306,7 +310,7 @@ class SubpyConn:
return starred
# Returns a requests Response object, the data stream can be found in Response.content
def stream(self, song_id: str, maxBitRate: int | None = None) -> requests.Response:
def stream(self, song_id: str, maxBitRate: int | None = None, format: str | None = None) -> requests.Response:
url = self.base_url + '/rest/stream?'
payload = self._getBasePayload()
@@ -314,6 +318,9 @@ class SubpyConn:
if maxBitRate:
payload['maxBitRate'] = maxBitRate
if format:
payload['format'] = format
payload['id'] = song_id
return requests.get(url + self._encodePayload(payload), stream=True)
@@ -381,6 +388,21 @@ class SubpyConn:
if response['status'] != 'ok':
raise BadSubsonicResponse()
"""
https://opensubsonic.netlify.app/docs/endpoints/setrating/
id A string which uniquely identifies the file (song) or folder (album/artist) to rate.
rating The rating between 1 and 5 (inclusive), or 0 to remove the rating: only accepts ints
"""
def setRating(self, id: str, rating: int) -> None:
url = self.base_url + '/rest/setRating?'
payload = self._getBasePayload()
payload['id'] = id
payload['rating'] = rating
requests.get(url + self._encodePayload(payload))
"""
https://opensubsonic.netlify.app/docs/endpoints/scrobble/
@@ -448,5 +470,80 @@ class SubpyConn:
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']
+542 -228
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from subpy!")
if __name__ == "__main__":
main()
+211
View File
@@ -87,3 +87,214 @@
width: 100%;
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 {
}
+14
View File
@@ -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
View File
@@ -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
Generated
+381
View File
@@ -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
View File
@@ -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