Merge pull request 'Songs Screen UI Redo' (#3) from songs-screen-redo into main

Reviewed-on: #3
This commit is contained in:
0x01FE committed 2025-10-22 11:28:57 -05:00
commit 9c462679c8
6 files changed
+222 -13

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+60
View File
@@ -0,0 +1,60 @@
import requests
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 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)
print(len(response.content))
return [SubSonic.Song(song_d) for song_d in response.json()]
+1 -1
View File
@@ -35,4 +35,4 @@ Artists Page
View of favorite songs View of favorite songs
![View of favorited songs](.attachments/favorite-songs.png) ![View of favorited songs](.attachments/songs-screen-favourites.png)
+144 -12
View File
@@ -23,6 +23,7 @@ BITRATE = int(BITRATE) if BITRATE else BITRATE
IMAGE_MODE = config.getboolean('GENERAL', 'GUI') IMAGE_MODE = config.getboolean('GENERAL', 'GUI')
import SubSonic import SubSonic
import Navidrome
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
import pygame import pygame
@@ -34,7 +35,7 @@ from textual import on, events, work
from textual.binding import Binding from textual.binding import Binding
from textual.reactive import reactive from textual.reactive import reactive
from textual.app import App, ComposeResult, RenderResult from textual.app import App, ComposeResult, RenderResult
from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule, Button from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule, Button, ListView, ListItem
from textual.widget import Widget from textual.widget import Widget
from textual.containers import Horizontal, Vertical, Container, Center from textual.containers import Horizontal, Vertical, Container, Center
from textual.screen import Screen, ModalScreen from textual.screen import Screen, ModalScreen
@@ -46,9 +47,12 @@ from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True ImageFile.LOAD_TRUNCATED_IMAGES = True
u, p = [s.strip() for s in open('auth', 'r').read().split(',')] 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)
conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net")
pygame.mixer.init() pygame.mixer.init()
@@ -197,7 +201,7 @@ class ArtistAlbumsGUI(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
yield Label(classes='artistNameHeader') yield Label(classes='artistNameHeader')
yield Rule() yield Rule()
@@ -297,7 +301,7 @@ class Artists(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
with Vertical(id="artistContainer"): with Vertical(id="artistContainer"):
yield Input(placeholder="Search") yield Input(placeholder="Search")
@@ -438,7 +442,7 @@ class ArtistsGUI(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
yield Input(placeholder="Search", id="artistSearchBar") yield Input(placeholder="Search", id="artistSearchBar")
with Horizontal(): with Horizontal():
@@ -479,6 +483,10 @@ class ArtistsGUI(Screen):
artist_panel.set_artist(self.artists[self.artists_displayed]) artist_panel.set_artist(self.artists[self.artists_displayed])
self.artists_displayed += 1 self.artists_displayed += 1
@on(events.ScreenResume)
def on_screen_resume(self, event: events.ScreenResume) -> None:
app.sub_title = "Artists"
def update_pager(self) -> None: def update_pager(self) -> None:
pager = self.query_one("#page-counter", Label) pager = self.query_one("#page-counter", Label)
pager.update(f"{self.page} ... {self.pages}") pager.update(f"{self.page} ... {self.pages}")
@@ -565,7 +573,7 @@ class Albums(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("* F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("* F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
with Vertical(id="albumContainer"): with Vertical(id="albumContainer"):
yield Input(placeholder="Search", id="albumSearchBar") yield Input(placeholder="Search", id="albumSearchBar")
@@ -583,6 +591,10 @@ class Albums(Screen):
for album in self.albums: for album in self.albums:
albumTable.add_row(album.title, album.artist, album.songCount, album.playCount, album.year, album.duration, key=album.id) albumTable.add_row(album.title, album.artist, album.songCount, album.playCount, album.year, album.duration, key=album.id)
@on(events.ScreenResume)
def on_screen_resume(self, event: events.ScreenResume) -> None:
app.sub_title = "Albums"
@on(DataTable.RowSelected) @on(DataTable.RowSelected)
def album_selected(self, event: DataTable.RowSelected) -> None: def album_selected(self, event: DataTable.RowSelected) -> None:
app.install_screen(AlbumSongs(event.row_key.value), 'albumSongs') app.install_screen(AlbumSongs(event.row_key.value), 'albumSongs')
@@ -610,7 +622,7 @@ class Playlists(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("* F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("* F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
yield DataTable() yield DataTable()
yield Footer() yield Footer()
@@ -626,6 +638,10 @@ class Playlists(Screen):
for playlist in self.playlists: 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:
app.sub_title = "Playlists"
@on(DataTable.RowHighlighted) @on(DataTable.RowHighlighted)
def row_highlighted(self, event: DataTable.RowHighlighted) -> None: def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
self.selected_playlist_id = event.row_key.value self.selected_playlist_id = event.row_key.value
@@ -701,6 +717,118 @@ class PlaylistSongs(Screen):
app.play_song(q.pop(0)) app.play_song(q.pop(0))
app.song_queue = q app.song_queue = q
class SongsScreen(Screen):
BINDINGS = [
("p", "play_song()", "Play Song"),
("q", "queue_song()", "Queue Song"),
("m", "shuffle_mix()", 'Shuffle Play'),
('a', "page_backward()", "Prev. Page"),
('d', "page_forward()", "Next Page")
]
songs: list[SubSonic.Song]
selected_song_id: str | None = None
# 0 = All, 1 = Favourites
mode: int = 0
page: dict[int, int] = {
0: 0,
1: 0
}
def compose(self) -> ComposeResult:
yield Header(icon='')
with Horizontal(id="headerbs"):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("* F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
with Horizontal(id='songsScreen'):
yield ListView(
ListItem(Label('All')),
ListItem(Label('Favourites')),
id='songModeSelectorList'
)
yield DataTable(id='songDatatable')
yield Footer()
def on_mount(self) -> None:
app.sub_title = "Songs"
self.update_datatable()
# TODO @0x01fe : find better way to do space
@on(ListView.Selected)
def mode_selected(self, e: ListView.Selected) -> None:
self.mode = e.index
self.update_datatable()
@on(events.ScreenResume)
def on_screen_resume(self, event: events.ScreenResume) -> None:
app.sub_title = "Songs"
def _get_offset(self) -> int:
return (self.page[self.mode]) * 30
def action_shuffle_mix(self) -> None:
if self.mode == 0:
q = conn.getRandomSongs(size=200)
app.play_song(q.pop(0))
app.song_queue = q
elif self.mode == 1:
q = self.songs.copy()
random.shuffle(q)
app.play_song(q.pop(0))
app.song_queue = q
def action_page_forward(self) -> None:
self.page[self.mode] += 1
self.update_datatable()
def action_page_backward(self) -> None:
if self.page[self.mode] - 1 < 0:
return
self.page[self.mode] -= 1
self.update_datatable()
def update_datatable(self) -> None:
datatable = self.query_one(DataTable)
if self.mode == 0:
self.update_all(datatable)
elif self.mode == 1:
self.update_favorites(datatable)
def update_favorites(self, dt: DataTable) -> None:
self.songs = conn.getStarredSongs()
space = 30
offset = self._get_offset()
dt.clear()
for song in self.songs[offset:offset + space]:
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
def update_all(self, dt: DataTable) -> None:
space = 30
offset = self._get_offset()
self.songs = app.navidrome.getAllSongs(offset + space, offset)
dt.cursor_type = "row"
dt.clear()
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)
class Favourites(Screen): class Favourites(Screen):
BINDINGS = [ BINDINGS = [
('p', 'play()', 'Play Song'), ('p', 'play()', 'Play Song'),
@@ -717,7 +845,7 @@ class Favourites(Screen):
yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("* F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("* F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
yield DataTable() yield DataTable()
yield Footer() yield Footer()
@@ -946,7 +1074,7 @@ class Player(Screen):
yield Label(Text("* F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("* F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb")
yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb")
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
with Center(id='playerTrackTitleBox'): with Center(id='playerTrackTitleBox'):
@@ -1040,7 +1168,7 @@ class SubPy(App):
Binding('f1', "switch_mode('main')", 'Overview', show=False), Binding('f1', "switch_mode('main')", 'Overview', show=False),
Binding('f2', "switch_mode('artists')", "Artists", show=False), Binding('f2', "switch_mode('artists')", "Artists", show=False),
Binding('f3', "switch_mode('albums')", "Albums", show=False), Binding('f3', "switch_mode('albums')", "Albums", show=False),
Binding('f4', "switch_mode('favourites')", "Favourites", show=False), Binding('f4', "switch_mode('songs')", "Songs", show=False),
Binding('f5', "switch_mode('playlists')", "Playlists", show=False) Binding('f5', "switch_mode('playlists')", "Playlists", show=False)
] ]
@@ -1048,7 +1176,7 @@ class SubPy(App):
"main" : Player, "main" : Player,
"artists" : ArtistsGUI, "artists" : ArtistsGUI,
"albums" : Albums, "albums" : Albums,
"favourites" : Favourites, "songs" : SongsScreen,
"playlists" : Playlists "playlists" : Playlists
} }
@@ -1060,6 +1188,8 @@ class SubPy(App):
is_paused: bool = False is_paused: bool = False
place: int = 0 place: int = 0
navidrome: Navidrome.NavidromeAPI
music_command_queue: multiprocessing.Queue music_command_queue: multiprocessing.Queue
check_playing_timer: Timer | None = None check_playing_timer: Timer | None = None
@@ -1071,6 +1201,8 @@ class SubPy(App):
self.check_music_commands = self.set_interval(2/3, self._music_player_loop, pause=False) self.check_music_commands = self.set_interval(2/3, self._music_player_loop, pause=False)
self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL)
@work(exclusive=True) @work(exclusive=True)
async def play_song(self, song: SubSonic.Song) -> None: async def play_song(self, song: SubSonic.Song) -> None:
self.currently_playing = song self.currently_playing = song
+17
View File
@@ -169,3 +169,20 @@ QueueScreen {
border: grey; border: grey;
} }
#songModeSelectorList {
height: 100%;
width: 0.2fr;
}
#songModeSelectorList Label {
padding: 1 2;
}
#songDatatable {
margin: 1 0 0 1;
width: 1fr;
}
# SongsScreen {
# background: red;
# }