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

+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()