From 2295af7b56db63eac676c21f22b6eda9169cbbf7 Mon Sep 17 00:00:00 2001 From: 0x01FE <0x01fe@noreply.localhost> Date: Wed, 4 Mar 2026 11:30:52 -0600 Subject: [PATCH] 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 Reviewed-on: https://127.0.0.1:3000/0x01FE/subpy/pulls/6 --- .gitignore | 5 +- Navidrome.py | 27 +++- README.md | 2 +- SubSonic.py | 146 ++++++++++++++++++---- app.py | 280 +++++++++++++++++++++++------------------ main.tcss | 95 ++++++++++++++ pyproject.toml | 2 +- roadmap.md | 22 ++++ uv.lock | 53 +------- widgets.py | 332 +++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 763 insertions(+), 201 deletions(-) create mode 100644 widgets.py diff --git a/.gitignore b/.gitignore index 3a60f5d..03a2dfc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -__pycache__ +__pycache__/ .cache/ +examples/ +.venv/ *.log auth +test.py diff --git a/Navidrome.py b/Navidrome.py index 5ef2107..321d629 100644 --- a/Navidrome.py +++ b/Navidrome.py @@ -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'] diff --git a/README.md b/README.md index 873a2b4..98d23b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SubSonic.py b/SubSonic.py index 146ddc7..e792fd4 100644 --- a/SubSonic.py +++ b/SubSonic.py @@ -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'] + diff --git a/app.py b/app.py index e829708..5f8eb1a 100644 --- a/app.py +++ b/app.py @@ -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() diff --git a/main.tcss b/main.tcss index d7d2770..7e7573d 100644 --- a/main.tcss +++ b/main.tcss @@ -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 { + +} diff --git a/pyproject.toml b/pyproject.toml index 147a300..d260ddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/roadmap.md b/roadmap.md index 0f33396..ebf0c09 100644 --- a/roadmap.md +++ b/roadmap.md @@ -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 \ No newline at end of file diff --git a/uv.lock b/uv.lock index 8503558..050100e 100644 --- a/uv.lock +++ b/uv.lock @@ -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]] diff --git a/widgets.py b/widgets.py new file mode 100644 index 0000000..b547359 --- /dev/null +++ b/widgets.py @@ -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 \ No newline at end of file