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
This commit is contained in:
0x01FEandJackson Hodge committed 2026-03-04 11:30:52 -06:00
1 parent d3de6ee914
commit 2295af7b56
10 files changed
+763 -201

No files matched your search

+4 -1
View File
@@ -1,6 +1,9 @@
__pycache__
__pycache__/
.cache/
examples/
.venv/
*.log
auth
test.py
+24 -3
View File
@@ -1,4 +1,5 @@
import requests
import logging
import urllib.parse
import SubSonic
@@ -7,7 +8,7 @@ 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 WebUI.
The endpoints are reverse engineered from observing requests made by the Navidrome server webUI.
"""
class NavidromeAPI:
username: str
@@ -53,7 +54,6 @@ class NavidromeAPI:
}
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
print(len(response.content))
return [SubSonic.Song(song_d) for song_d in response.json()]
@@ -94,4 +94,25 @@ class NavidromeAPI:
def isSongStarred(self, id: str) -> bool:
info = self.getSong(id)
return info[0]['starred']
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 -1
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
+121 -25
View File
@@ -38,24 +38,24 @@ class Album:
songCount: int
playCount: int
year: int | None
duration: str
duration_f: str
def __init__(self, album_info: dict):
self.id = album_info['id']
self.name = album_info['name']
self.title = album_info['title'] if 'title' in album_info else self.name
self.album = album_info['album'] if 'album' in album_info else self.name
self.artist = album_info['artist']
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']:
@@ -384,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/
@@ -450,4 +469,81 @@ class SubpyConn:
random_songs.append(Song(song))
return random_songs
"""
https://www.subsonic.org/pages/api.jsp#getLyrics
"""
def getLyrics(self, artist: str, title: str):
url = self.base_url + '/rest/getLyrics?'
payload = self._getBasePayload()
payload['artist'] = artist
payload['title'] = title
response = requests.get(url + self._encodePayload(payload)).json()
return response
"""
https://www.subsonic.org/pages/api.jsp#createPlaylist
songs (Optional): can either be a list of songId's as a string OR a list of SubPy Song Objects
"""
def createPlaylist(self, name: str, songs: list[str] | list[Song] | None = None) -> Playlist:
url = self.base_url + '/rest/createPlaylist?'
payload = self._getBasePayload()
payload['name'] = name
response = requests.get(url + self._encodePayload(payload)).json()
return Playlist(response['subsonic-response']['playlist'])
"""
https://www.subsonic.org/pages/api.jsp#updatePlaylist
returns True on success
"""
def updatePlaylist(self, playlistId: str, name: str | None = None, comment: str | None = None, public: bool | None = None, songIdToAdd: list[str] | None = None) -> bool:
url = self.base_url + '/rest/updatePlaylist?'
payload = self._getBasePayload()
payload['playlistId'] = playlistId
if songIdToAdd:
payload['songIdToAdd'] = songIdToAdd
response = requests.get(url + self._encodePayload(payload)).json()
return bool(response['subsonic-response'])
"""
https://www.subsonic.org/pages/api.jsp#createShare
Params:
m_id: ID of a song, album or video to share. Use one id parameter for each entry to share.
description (OPTIONAL): A user-defined description that will be displayed to people visiting the shared media.
expires (OPTIONAL): The time at which the share expires. Given as milliseconds since 1970. Default is a year.
Returns:
List of share links?
TODO @0x01FE : Finish this method
"""
def createShare(self, m_id: str, description: str | None = None, expires: int | None = None) -> list[str]:
url = self.base_url + '/rest/createShare?'
payload = self._getBasePayload()
payload['id'] = m_id
if expires:
payload['expires'] = expires
response = requests.get(url + self._encodePayload(payload)).json()
return response['subsonic-response']['shares']['share'][0]['url']
+162 -118
View File
@@ -29,6 +29,8 @@ os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
import pygame
import pynput
from widgets import SongTableColumnConfig, SongTable
from rich.text import Text
from textual import on, events, work
@@ -47,13 +49,8 @@ from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
MUSIC_SERVER_URL = 'https://music.0x01fe.net'
conn = SubSonic.SubpyConn(user, passw, MUSIC_SERVER_URL)
pygame.mixer.init()
SONG_LIKED_CHAR = '♥'
@@ -64,62 +61,23 @@ THREE_BARS_CHAR = '≡'
class AlbumSongs(Screen):
BINDINGS = [
('escape', 'die()', "Back"),
('p', 'play()', 'Play Song'),
('q' 'queue()', 'Queue Song'),
('m', 'shuffle_mix()', 'Shuffle Play')
('escape', 'die()', "Back")
]
selected_song_id: str | None = None
songs: list[SubSonic.Song]
def __init__(self, album_id: str):
self.album_id = album_id
super().__init__()
def compose(self) -> ComposeResult:
yield DataTable(id="albumSongs")
songs = conn.getAlbumSongs(self.album_id)
yield SongTable(SongTableColumnConfig(track_number=True, album=False), songs, id="albumSongs")
yield Footer()
def on_mount(self) -> None:
self.songs = conn.getAlbumSongs(self.album_id)
songsTable: DataTable = self.query_one(DataTable)
songsTable.cursor_type = 'row'
songsTable.add_columns("#", "Title", "Artist", "Duration", "Bitrate")
for song in self.songs:
songsTable.add_row(song.trackNumber, song.title, song.artist, song.duration, song.bitRate, key=song.id)
@on(DataTable.RowHighlighted)
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self.selected_song_id = event.row_key.value
def action_play(self) -> None:
song_index = self._find_selected_song()
app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:]
def action_queue(self) -> None:
app.queue_song(self._find_selected_song())
# Returns index of selected song
def _find_selected_song(self) -> int:
for i, song in enumerate(self.songs):
if song.id == self.selected_song_id:
return i
# Pops and uninstalls self
def action_die(self) -> None:
app.pop_screen()
app.uninstall_screen("albumSongs")
def action_shuffle_mix(self) -> None:
q = self.songs.copy()
random.shuffle(q)
app.play_song(q.pop(0))
app.song_queue = q
class ArtistAlbums(Screen):
BINDINGS = [
('escape', 'die()', "Back")
@@ -315,7 +273,7 @@ class Artists(Screen):
yield Footer()
def on_mount(self) -> None:
app.sub_title = 'Artists'
self.app.sub_title = 'Artists'
self.artists = conn.getArtists()
artistList = self.query_one(OptionList)
@@ -377,7 +335,8 @@ class ArtistArt(Static):
if CACHE and os.path.exists(cache_path):
try:
self.art = Image.open(cache_path)
iamge = Image.open(cache_path)
self.art = image
except Exception as e:
logging.error(f"Failed to load cached artist art {cache_path}: {e}")
@@ -674,7 +633,7 @@ class Albums(Screen):
dt.clear()
for album in albums[:space]:
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration, key=album.id)
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration_f, key=album.id)
self.last_mode = self.mode
@@ -703,11 +662,35 @@ class Albums(Screen):
self._update_datatable(force_update=True)
class PlaylistNameForm(ModalScreen[str]):
"""Screen for the creation of a playlist, merely gets the name"""
BINDINGS = [
('escape', 'die', 'Back')
]
def compose(self) -> ComposeResult:
with Container(id='playlistFormModal'):
yield Label("Please enter your playlist name:")
yield Input()
with Container(id='PSbutton'):
yield Button("Enter")
@on(Button.Pressed)
def submit(self, event: Button.Pressed) -> None:
playlist_name = self.query_one(Input).value
self.dismiss(playlist_name)
def action_die(self) -> None:
app.pop_screen()
class Playlists(Screen):
playlists: list[SubSonic.Playlist] | None = None
selected_playlist_id: str = None
BINDINGS = [
('C', 'create_playlist()', 'Create Playlist')
]
def compose(self) -> ComposeResult:
yield Header(icon="")
@@ -729,7 +712,13 @@ class Playlists(Screen):
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)
playlistTable.add_row(
playlist.name,
playlist.songCount,
playlist.duration,
playlist.owner,
key=playlist.id
)
@on(events.ScreenResume)
def on_screen_resume(self, event: events.ScreenResume) -> None:
@@ -744,6 +733,23 @@ class Playlists(Screen):
app.install_screen(PlaylistSongs(self._find_selected_playlist()), "playlistSongs")
app.push_screen('playlistSongs')
# Needs to popup a modal text box form?
def action_create_playlist(self) -> None:
def create_playlist_api(playlist_name: str) -> None:
new_playlist = conn.createPlaylist(playlist_name)
playlistTable = self.query_one(DataTable)
playlistTable.add_row(
new_playlist.name,
new_playlist.songCount,
new_playlist.duration,
new_playlist.owner,
key=new_playlist.id
)
self.playlists.append(new_playlist)
app.push_screen(PlaylistNameForm(), create_playlist_api)
def _find_selected_playlist(self) -> SubSonic.Playlist:
for playlist in self.playlists:
if playlist.id == self.selected_playlist_id:
@@ -751,68 +757,29 @@ class Playlists(Screen):
class PlaylistSongs(Screen):
BINDINGS = [
('escape', 'die()', "Back"),
('p', 'play()', 'Play Song'),
('q', 'queue()', 'Queue Song'),
('m', 'shuffle_mix()', 'Shuffle Play')
('escape', 'die()', "Back")
]
selected_song_id: str | None = None
songs: list[SubSonic.Song]
def __init__(self, playlist: SubSonic.Playlist):
self.playlist = playlist
self.songs = conn.getPlaylist(playlist.id)
super().__init__()
def compose(self) -> ComposeResult:
w = self.app.screen.size.width
self.sub_title = self.playlist.name
songs = conn.getPlaylist(self.playlist.id)
yield Header(icon="")
yield DataTable()
yield SongTable(songs=songs, max_col_width=(w // 3) - 5)
yield Footer()
def on_mount(self) -> None:
songTable: DataTable = self.query_one(DataTable)
songTable.cursor_type = "row"
songTable.add_columns("Title", "Album", "Artist", "Plays", "Duration", "Bitrate")
for i, song in enumerate(self.songs):
songTable.add_row(song.title, song.album, song.artist, song.playCount, song.duration, song.bitRate, key=str(i))
# Pops and uninstalls self
def action_die(self) -> None:
app.pop_screen()
app.uninstall_screen("playlistSongs")
@on(DataTable.RowHighlighted)
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self.selected_song_id = self.songs[int(event.row_key.value)].id
def action_play(self) -> None:
song_index = self._find_selected_song()
app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:]
def action_queue(self) -> None:
song_id: int = self._find_selected_song()
app.queue_song(self.songs[song_id])
# Returns index of selected song
def _find_selected_song(self) -> int:
for i, song in enumerate(self.songs):
if song.id == self.selected_song_id:
return i
def action_shuffle_mix(self) -> None:
q = self.songs.copy()
random.shuffle(q)
app.play_song(q.pop(0))
app.song_queue = q
class SongsScreen(Screen):
BINDINGS = [
("p", "play_song()", "Play Song"),
("p", "play()", "Play Song"),
("q", "queue_song()", "Queue Song"),
("m", "shuffle_mix()", 'Shuffle Play'),
('a', "page_backward()", "Prev. Page"),
@@ -865,6 +832,21 @@ class SongsScreen(Screen):
def on_screen_resume(self, event: events.ScreenResume) -> None:
app.sub_title = "Songs"
@on(DataTable.RowHighlighted)
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self.selected_song_id = event.row_key.value
def _find_selected_song(self) -> int | None:
for i, song in enumerate(self.songs):
if song.id == self.selected_song_id:
return i
return None
def action_play(self) -> None:
song_index = self._find_selected_song()
app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:]
def _get_offset(self) -> int:
return (self.page[self.mode]) * 30
@@ -907,7 +889,7 @@ class SongsScreen(Screen):
dt.clear()
for song in self.songs[offset:offset + space]:
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
def update_all(self, dt: DataTable) -> None:
space = 30
@@ -920,7 +902,7 @@ class SongsScreen(Screen):
dt.add_columns("Title", "Album", "Artist", "Plays", "Duration")
for song in self.songs:
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
class CoverArtPixels(Container):
@@ -948,7 +930,8 @@ class CoverArtPixels(Container):
if CACHE and os.path.exists(cache_path):
try:
self.cover = Image.open(cache_path)
image = Image.open(cache_path)
self.cover = image
except Exception as e:
logging.error(f"Failed to load cached cover art {cache_path}: {e}")
@@ -1263,12 +1246,14 @@ class SubPy(App):
"playlists" : Playlists
}
conn: SubSonic.SubpyConn
currently_playing: SubSonic.Song | None = reactive(SubSonic.Song())
is_playing: bool = False
song_queue: list[SubSonic.Song] = reactive([])
user_queue: list[SubSonic.Song] = reactive([])
play_history: list[SubSonic.Song] = []
is_paused: bool = False
is_loading: bool = False
place: int = 0
navidrome: Navidrome.NavidromeAPI
@@ -1278,6 +1263,8 @@ class SubPy(App):
check_playing_timer: Timer | None = None
check_music_commands: Timer
_prefetch_started_for: str | None = None
"""
Modes:
0: standard queue, is done when queue is done
@@ -1286,6 +1273,10 @@ class SubPy(App):
"""
queue_mode: int = 0
def __init__(self, conn: SubSonic.SubpyConn):
self.conn = conn
super().__init__()
def on_mount(self) -> None:
self.title = "SubPy"
self.switch_mode('main')
@@ -1294,9 +1285,35 @@ class SubPy(App):
self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL)
@work(exclusive=False, thread=True)
def _prefetch_next_song(self, song: SubSonic.Song) -> None:
audio_cache_path = f'.cache/audio/{song.id}.ogg'
if os.path.exists(audio_cache_path):
logging.debug(f'Prefetch: "{song.title}" already cached.')
return
logging.debug(f'Prefetch: downloading "{song.title}" by {song.artist}...')
try:
if BITRATE:
r = self.conn.stream(song.id, maxBitRate=BITRATE)
else:
r = self.conn.stream(song.id)
audio_content: bytes = r.content
if CACHE:
self._cache_audio(audio_cache_path, audio_content)
logging.debug(f'Prefetch: "{song.title}" cached successfully.')
except Exception as e:
logging.error(f'Prefetch failed for "{song.title}": {e}')
@work(exclusive=True)
async def play_song(self, song: SubSonic.Song) -> None:
self.is_loading = True
self.currently_playing = song
self._prefetch_started_for = None
self.is_paused = False
self.is_playing = True
logging.debug(f"Playing song {song.title} by {song.artist}.")
@@ -1329,6 +1346,8 @@ class SubPy(App):
pygame.mixer.music.load(audio_data)
pygame.mixer.music.play()
self.is_loading = False
self.check_playing_timer = self.set_interval(1 / 5, self.check_done_playing, pause=False)
self.progess_timer = self.set_interval(0.3, self.update_progress, pause=False)
@@ -1358,12 +1377,16 @@ class SubPy(App):
file.write(content)
def check_done_playing(self) -> None:
if self.is_loading:
return
if not pygame.mixer.music.get_busy() and not self.is_paused and self.currently_playing:
if self.check_playing_timer:
self.check_playing_timer.stop()
if self.queue_mode == 2:
self.play_song(app.currently_playing)
if self.check_playing_timer:
self.check_playing_timer.stop()
return
self.play_history.append(self.currently_playing)
@@ -1386,10 +1409,9 @@ class SubPy(App):
if next_song:
self.play_song(next_song)
if self.check_playing_timer:
self.check_playing_timer.stop()
def action_skip(self) -> None:
if self.is_loading:
return
if self.check_playing_timer:
self.check_playing_timer.stop()
@@ -1427,22 +1449,36 @@ class SubPy(App):
self.play_song(song_to_play)
def update_progress(self) -> None:
if isinstance(self.screen, Player) and self.currently_playing:
current_place_label = self.screen.query_one(CurrentPlace)
if self.currently_playing:
place = pygame.mixer.music.get_pos()
current_place_label.place = place
duration_label = self.screen.query_one(TotalDuration)
duration_label.duration = self.currently_playing.duration
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
progress: int = round(place/1000)
progressbar.update(
total = self.currently_playing.duration,
progress=progress
)
if self.currently_playing.duration > 0 and progress >= self.currently_playing.duration * 0.8:
next_song: SubSonic.Song | None = None
if self.user_queue:
next_song = self.user_queue[0]
elif self.song_queue:
next_song = self.song_queue[0]
elif self.queue_mode == 2:
next_song = self.currently_playing
if next_song and self._prefetch_started_for != next_song.id:
self._prefetch_started_for = next_song.id
self._prefetch_next_song(next_song)
if isinstance(self.screen, Player):
current_place_label = self.screen.query_one(CurrentPlace)
current_place_label.place = place
duration_label = self.screen.query_one(TotalDuration)
duration_label.duration = self.currently_playing.duration
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
progressbar.update(
total = self.currently_playing.duration,
progress=progress
)
def queue_song(self, song: SubSonic.Song) -> None:
logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.')
@@ -1502,11 +1538,19 @@ if __name__ == '__main__':
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
q = multiprocessing.Queue(-1)
q = multiprocessing.Queue(0)
p = multiprocessing.Process(target=listen_media_keys, args=(q,))
p.start()
app = SubPy()
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
conn = SubSonic.SubpyConn(
user,
passw,
MUSIC_SERVER_URL,
client_name="SubPy"
)
app = SubPy(conn)
app.music_command_queue = q
app.run()
+95
View File
@@ -169,6 +169,48 @@ QueueScreen {
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;
@@ -203,3 +245,56 @@ QueueScreen {
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 {
}
+1 -1
View File
@@ -3,7 +3,7 @@ name = "subpy"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
requires-python = "<3.14,>=3.13"
dependencies = [
"pillow>=12.0.0",
"pygame>=2.6.1",
+22
View File
@@ -8,3 +8,25 @@
- 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
+1 -52
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.13"
requires-python = "==3.13.*"
[[package]]
name = "certifi"
@@ -33,22 +33,6 @@ wheels = [
{ 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/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ 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" },
]
@@ -148,31 +132,6 @@ wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
[[package]]
@@ -232,8 +191,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/dc/6d63019133e39e2b2
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" },
{ url = "https://files.pythonhosted.org/packages/35/b1/6df7d4b0d9f0088855a59f6af59230d1191f78fa84ca68851723272f1916/pyobjc_core-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c2709ff43ac5c2e9e2c574ae515d3aa0e470345847a4d96c5d4a04b1b86e966d", size = 672302, upload-time = "2025-10-21T07:52:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/f8/10/3a029797c0a22c730ee0d0149ac34ab27afdf51667f96aa23a8ebe7dc3c9/pyobjc_core-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:eb6b987e53291e7cafd8f71a80a2dd44d7afec4202a143a3e47b75cb9cdb5716", size = 713255, upload-time = "2025-10-21T07:53:25.478Z" },
]
[[package]]
@@ -250,8 +207,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/8c/79/0b7a00bcc7561c816
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" },
{ url = "https://files.pythonhosted.org/packages/ce/8b/336788981a3c1aa00e75d021a5ed00e453587da1eee0d55bb8b674f2b623/pyobjc_framework_applicationservices-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b153a0b8e915751ca50651be6f9fe002ef7536677f5c37a4dff0f3fd98e5b16a", size = 33007, upload-time = "2025-10-21T07:54:21.971Z" },
{ url = "https://files.pythonhosted.org/packages/a8/50/0e300544e8204d02b4a0477fa157e904921c98b15f67e19b4a49a80f02c9/pyobjc_framework_applicationservices-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:56f8fabdc3972fc9a97630b24c31d8b852502c3273071ab3d3b467cc5e7c6431", size = 33250, upload-time = "2025-10-21T07:54:25.395Z" },
]
[[package]]
@@ -265,8 +220,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de64
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" },
{ url = "https://files.pythonhosted.org/packages/0f/15/0a6122e430d0e2ba27ad0e345b89f85346805f39d6f97eea6430a74350d9/pyobjc_framework_cocoa-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a2b6fb9ab3e5ab6db04dfa17828a97894e7da85dd8600885c72a0c2c2214d618", size = 384890, upload-time = "2025-10-21T08:00:25.286Z" },
{ url = "https://files.pythonhosted.org/packages/79/d7/1a3ad814d427c08b99405e571e47a0219598930ad73850ac02d164d88cd0/pyobjc_framework_cocoa-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:32ff10250a57f72a0b6eca85b790dcc87548ff71d33d0436ffb69680d5e2f308", size = 388925, upload-time = "2025-10-21T08:00:47.309Z" },
]
[[package]]
@@ -282,8 +235,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/36/32ec183e555b73152
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" },
{ url = "https://files.pythonhosted.org/packages/07/e4/96caefd91817d0f82aaae089e4421cbbef2a216933b5c98435ee2927fbef/pyobjc_framework_coretext-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6ecf89af6de87072f1615fb89d7ed51b345000850a9b827774f262bf6be5acac", size = 30104, upload-time = "2025-10-21T08:05:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b5/9152c1a2d8a6fb06d48a36d95b5bb919e820a2f623ca8313ab5eba263be0/pyobjc_framework_coretext-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ddc34c91d16a653db81963141d29f8fc82550fc7a39ed39ff0332764d844ffe1", size = 30714, upload-time = "2025-10-21T08:05:07.092Z" },
]
[[package]]
@@ -298,8 +249,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/0b/3c34fc9de790daff5
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" },
{ url = "https://files.pythonhosted.org/packages/8a/23/ec1804bd10c409fe98ba086329569914fd10b6814208ca6168e81ca0ec1a/pyobjc_framework_quartz-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a2cde43ddc5d2a9ace13af38b4a9ee70dbd47d1707ec6b7185a1a3a1d48e54f9", size = 219581, upload-time = "2025-10-21T08:18:30.219Z" },
{ url = "https://files.pythonhosted.org/packages/86/c2/cf89fda2e477c0c4e2a8aae86202c2891a83bead24e8a7fc733ff490dffc/pyobjc_framework_quartz-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9b928d551ec779141558d986684c19f8f5742251721f440d7087257e4e35b22b", size = 224613, upload-time = "2025-10-21T08:18:45.39Z" },
]
[[package]]
+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