diff --git a/.attachments/artist-discography.png b/.attachments/artist-discography.png new file mode 100644 index 0000000..23d658d Binary files /dev/null and b/.attachments/artist-discography.png differ diff --git a/.attachments/artists-menu.png b/.attachments/artists-menu.png new file mode 100644 index 0000000..d51488a Binary files /dev/null and b/.attachments/artists-menu.png differ diff --git a/.attachments/favorite-songs.png b/.attachments/favorite-songs.png new file mode 100644 index 0000000..0611056 Binary files /dev/null and b/.attachments/favorite-songs.png differ diff --git a/.attachments/player.png b/.attachments/player.png new file mode 100644 index 0000000..08f7512 Binary files /dev/null and b/.attachments/player.png differ diff --git a/.gitignore b/.gitignore index ed8ebf5..3a60f5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -__pycache__ \ No newline at end of file +__pycache__ +.cache/ + +*.log +auth + diff --git a/Artist.py b/Artist.py deleted file mode 100644 index 3fa0694..0000000 --- a/Artist.py +++ /dev/null @@ -1,15 +0,0 @@ -class Artist: - id: str - name: str - albumCount: int - coverArt: str - artistImageUrl: str - - def __init__(self, id: str, name: str, albumCount: str | int, coverArt: str, artistImageUrl: str): - self.id = id - self.name = name - self.albumCount = int(albumCount) - self.coverArt = coverArt - self.artistImageUrl = artistImageUrl - - diff --git a/ArtistIndex.py b/ArtistIndex.py deleted file mode 100644 index 634ae63..0000000 --- a/ArtistIndex.py +++ /dev/null @@ -1,65 +0,0 @@ -import xml.etree.ElementTree - -import Artist - - -class ArtistIndex: - name: str - artists: list[Artist.Artist] - - def __init__(self, indexInfo: dict, artists: list[dict]): - self.name = indexInfo['name'] - - self.artists = [] - for artist in artists: - self.artists.append(Artist.Artist( - artist['id'], - artist['name'], - artist['albumCount'], - artist['coverArt'], - artist['artistImageUrl'] - )) - - - -class ArtistIndexes: - indexes: list[ArtistIndex] - - def __init__(self, xml_data: bytes): - self._parseXml(xml_data.decode('utf-8')) - - def _parseXml(self, xml_data: str): - root = xml.etree.ElementTree.fromstring(xml_data) - - self.indexes: list[ArtistIndex] = [] - - if root.get('status') != 'ok': - return None - - in_index = False - artists = [] - index_info = None - for item in root.iter(): - print(item.tag, dict(item.items())) - - if item.tag == '{http://subsonic.org/restapi}index': - - if in_index: - in_index = not in_index - - self.indexes.append(ArtistIndex(index_info, artists)) - - artists = [] - else: - in_index = not in_index - index_info = dict(item.items()) - - elif item.tag == '{http://subsonic.org/restapi}artist': - artists.append(dict(item.items())) - - - - - - - diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff04739 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# 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. + +# Usage + +## Installation + +Python >= 3.12 + +1. Clone the repo using `git clone` +2. Install dependencies `python -m pip install -r requirements.txt` +3. Set up auth file + +## Auth File + +This program requires a file simply named `auth` that contains your username and password as such `username,password`. + +# Screenshots + +Main View with queue and cover art + +![Main music player view](.attachments/player.png) + +Artists Page + +![![Artists Page](.attachments/artists-menu.png)](.attachments/artists-menu.png) + +![View of an artists discography](.attachments/artist-discography.png) + + +View of favorite songs + +![View of favorited songs](.attachments/favorite-songs.png) diff --git a/SubSonic.py b/SubSonic.py index 5eddd07..bec6de4 100644 --- a/SubSonic.py +++ b/SubSonic.py @@ -1,37 +1,61 @@ +import time import random import string import hashlib import logging import urllib.parse -import xml.etree -import xml.etree.ElementTree - import requests logger = logging.getLogger(__name__) +class Playlist: + id: str + name: str + songCount: int + duration: str + owner: str + coverArtId: str + + def __init__(self, info: dict): + self.id = info['id'] + self.name = info['name'] + self.songCount = int(info['songCount']) + self.duration = time.strftime('%H:%M:%S', time.gmtime(int(info['duration']))) + self.owner = info['owner'] + self.coverArtId = info['coverArt'] class Album: id: str title: str name: str album: str + artist: str coverArtId: str duration: int artistId: str songCount: int + playCount: int + year: int | None + duration: str def __init__(self, album_info: dict): self.id = album_info['id'] self.title = album_info['title'] self.name = album_info['name'] self.album = album_info['album'] + self.artist = album_info['artist'] self.coverArtId = album_info['coverArt'] self.duration = int(album_info['duration']) self.artistId = album_info['artistId'] 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'])) + + 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 @@ -50,24 +74,28 @@ class Song: comment: str channelCount: int samplingRate: int + playCount: int | None + coverArtId: str | None - def __init__(self, song_info: dict): - self.id = song_info['id'] - self.title = song_info['title'] - self.album = song_info['album'] - self.artist = song_info['artist'] - self.trackNumber = int(song_info['track']) - self.contentType = song_info['contentType'] - self.suffix = song_info['suffix'] - self.duration = int(song_info['duration']) - self.bitRate = int(song_info['bitRate']) - self.path = song_info['path'] - self.discNumber = int(song_info['discNumber']) if 'discNumber' in song_info else None - self.albumId = song_info['albumId'] - self.artistId = song_info['artistId'] - self.comment = song_info['comment'] - self.channelCount = int(song_info['channelCount']) - self.samplingRate = int(song_info['samplingRate']) + def __init__(self, info: dict | None = {}): + 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.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.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 "" + self.albumId = info['albumId'] if 'albumId' in info else "" + self.artistId = info['artistId'] if 'artistId' in info else "" + self.comment = info['comment'] if 'comment' in info else "" + self.channelCount = int(info['channelCount']) if 'channelCount' in info else "" + 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 class Artist: id: str @@ -83,58 +111,17 @@ class Artist: self.coverArt = coverArt self.artistImageUrl = artistImageUrl + def from_dict(info: dict): + return Artist( + info['id'] if 'id' in info else None, + info['name'] if 'name' in info else None, + info['albumCount'] if 'albumCount' in info else None, + info['coverArt'] if 'coverArt' in info else None, + info['artistImageUrl'] if 'artistImageUrl' in info else None + ) -class ArtistIndex: - name: str - artists: list[Artist] - - def __init__(self, indexInfo: dict, artists: list[dict]): - self.name = indexInfo['name'] - - self.artists = [] - for artist in artists: - self.artists.append(Artist( - artist['id'], - artist['name'], - artist['albumCount'], - artist['coverArt'], - artist['artistImageUrl'] - )) - -class ArtistIndexes: - indexes: list[ArtistIndex] - - def __init__(self, xml_data: bytes): - self._parseXml(xml_data.decode('utf-8')) - - def _parseXml(self, xml_data: str): - root = xml.etree.ElementTree.fromstring(xml_data) - - self.indexes: list[ArtistIndex] = [] - - if root.get('status') != 'ok': - return None - - in_index = False - artists = [] - index_info = None - for item in root.iter(): - # print(item.tag, dict(item.items())) - - if item.tag == '{http://subsonic.org/restapi}index': - - if in_index: - in_index = not in_index - - self.indexes.append(ArtistIndex(index_info, artists)) - - artists = [] - else: - in_index = not in_index - index_info = dict(item.items()) - - elif item.tag == '{http://subsonic.org/restapi}artist': - artists.append(dict(item.items())) +class BadSubsonicResponse(Exception): + pass class SubpyConn: username: str @@ -153,7 +140,7 @@ class SubpyConn: self.salt_len = salt_len def _saltGenerator(self) -> None: - self.salt = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(self.salt_len)) + self.salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len)) def _hashPass(self) -> str: self._saltGenerator() @@ -165,19 +152,15 @@ class SubpyConn: "t" : self._hashPass(), "s" : self.salt, "v" : self.version, - "c" : self.client_name + "c" : self.client_name, + "f": "json" } def _encodePayload(self, payload: dict) -> str: return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus) - def getIndexes(self) -> ArtistIndexes: - url = self.base_url + '/rest/getIndexes?' - - return ArtistIndexes(requests.get(url + self._encodePayload(self._getBasePayload())).content) - def _getMusicDirectory(self, id: str): - url = 'https://' + self.base_url + '/rest/getMusicDirectory?' + url = self.base_url + '/rest/getMusicDirectory?' payload = self._getBasePayload() payload['id'] = id @@ -185,74 +168,285 @@ class SubpyConn: return requests.get(url + self._encodePayload(payload)) def getArtistAlbums(self, artist_id: str) -> list[Album]: - r: requests.Response = self._getMusicDirectory(artist_id) - xml_data: str = r.content.decode('utf-8') + response: requests.Response = self._getMusicDirectory(artist_id).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() - root: xml.etree.ElementTree.Element = xml.etree.ElementTree.fromstring(xml_data) albums: list[Album] = [] - - for item in root.iter(): - if item.tag == '{http://subsonic.org/restapi}child': - albums.append(Album(dict(item.items()))) + for item in response['directory']['child']: + albums.append(Album(item)) return albums def getAlbumSongs(self, album_id: str) -> list[Song]: - r: requests.Response = self._getMusicDirectory(album_id) - logger.debug(f"Requesting all songs for album {album_id} using url {self.base_url}") - logger.debug(f'Got response with status code {r.status_code}') + response: requests.Response = self._getMusicDirectory(album_id).json()['subsonic-response'] - xml_data: str = r.content.decode('utf-8') + if response['status'] != 'ok': + raise BadSubsonicResponse() - if r.status_code != 200: - return [] - - root: xml.etree.ElementTree.Element = xml.etree.ElementTree.fromstring(xml_data) songs: list[Song] = [] - - logger.debug(f"Request Status: {root.get('status')}") - if root.get('status') != 'ok': - return [] - - for item in root.iter(): - - if item.tag == '{http://subsonic.org/restapi}child': - songs.append(Song(dict(item.items()))) + for item in response['directory']['child']: + songs.append(Song(item)) return songs def getArtists(self) -> list[Artist]: - artistIndexes: ArtistIndexes = self.getIndexes() + url = self.base_url + '/rest/getArtists?' + + payload = self._getBasePayload() + + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() artists: list[Artist] = [] - for artistIndex in artistIndexes.indexes: - artists.extend(artistIndex.artists) + + for artist_index in response['artists']['index']: + for artist in artist_index['artist']: + artists.append(Artist.from_dict(artist)) return artists + def getAlbums(self, size: int | None = 25, offset: int | None = 0) -> list[Album]: + url = self.base_url + '/rest/getAlbumList?' + + payload = self._getBasePayload() + payload['type'] = 'alphabeticalByName' + payload['size'] = size + payload['offset'] = offset + + r = requests.get(url + self._encodePayload(payload)) + json_r = r.json() + + albums: list[Album] = [] + + if 'album' not in json_r['subsonic-response']['albumList']: + return [] + + for album in json_r['subsonic-response']['albumList']['album']: + albums.append(Album(album)) + + return albums + + def getAllAlbums(self) -> list[Album]: + all_albums: list[Album] = [] + + offset = 0 + while albums := self.getAlbums(50, offset): + all_albums.extend(albums) + offset += 50 + + return all_albums + + def getPlaylists(self) -> list[Playlist]: + url = self.base_url + "/rest/getPlaylists?" + + payload = self._getBasePayload() + + r = requests.get(url + self._encodePayload(payload)) + + json_r = r.json() + + playlists: list[Playlist] = [] + for playlist in json_r['subsonic-response']['playlists']['playlist']: + playlists.append(Playlist(playlist)) + + return playlists + + def getPlaylist(self, playlist_id: str) -> list[Song]: + url = self.base_url + "/rest/getPlaylist?" + + payload = self._getBasePayload() + payload['id'] = playlist_id + + r = requests.get(url + self._encodePayload(payload)) + + json_r = r.json() + + songs: list[Song] = [] + for song in json_r['subsonic-response']['playlist']['entry']: + songs.append(Song(song)) + + return songs + + def getStarred(self) -> list[Album | Song | Artist]: + url = self.base_url + "/rest/getStarred?" + + payload = self._getBasePayload() + + r = requests.get(url + self._encodePayload(payload)) + json_r = r.json() + + starred: list[Album | Song | Artist] = [] + for album in json_r['subsonic-response']['starred']['artist']: + starred.append(Album(album)) + + for artist in json_r['subsonic-response']['starred']['artist']: + starred.append(Artist.from_dict(artist)) + + for song in json_r['subsonic-response']['starred']['song']: + starred.append(Song(song)) + + return starred + + def getStarredSongs(self) -> list[Song]: + url = self.base_url + "/rest/getStarred?" + + payload = self._getBasePayload() + + r = requests.get(url + self._encodePayload(payload)) + json_r = r.json() + + starred: list[Song] = [] + for song in json_r['subsonic-response']['starred']['song']: + starred.append(Song(song)) + + return starred + # Returns a requests Response object, the data stream can be found in Response.content - def stream(self, song_id: str) -> requests.Response: + def stream(self, song_id: str, maxBitRate: int | None = None) -> requests.Response: url = self.base_url + '/rest/stream?' payload = self._getBasePayload() + if maxBitRate: + payload['maxBitRate'] = maxBitRate + payload['id'] = song_id return requests.get(url + self._encodePayload(payload), stream=True) + # Saves cover art id + def getCoverArt(self, cover_art_id: str, size: int | None = None, path: str | None = None) -> None: + url = self.base_url + '/rest/getCoverArt?' + + payload = self._getBasePayload() + payload['id'] = cover_art_id + + if size: + payload['size'] = size + + r = requests.get(url + self._encodePayload(payload)) + + with open(path if path else 'cover.png', 'wb') as file: + file.write(r.content) + + def getCoverArtRaw(self, cover_art_id: str, size: int | None = None) -> bytes: + url = self.base_url + '/rest/getCoverArt?' + + payload = self._getBasePayload() + payload['id'] = cover_art_id + + if size: + payload['size'] = size + + r = requests.get(url + self._encodePayload(payload)) + + return r.content + def getSong(self, song_id: str) -> Song: url = self.base_url + '/rest/getSong?' payload = self._getBasePayload() payload['id'] = song_id - r = requests.get(url + self._encodePayload(payload)) + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] - xml_data = r.content.decode('utf-8') + if response['status'] != 'ok': + raise BadSubsonicResponse() + + return Song(response['song']) + + def star(self, id: str) -> None: + url = self.base_url + '/rest/star?' + + payload = self._getBasePayload() + payload['id'] = id + + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() + + def unstar(self, id: str) -> None: + url = self.base_url + '/rest/unstar?' + + payload = self._getBasePayload() + payload['id'] = id + + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() + + """ + https://opensubsonic.netlify.app/docs/endpoints/scrobble/ + + id: str - unique identifier of the media to scrobble + OPTIONAL scrobble_time: int - posix time of when the song was listened to. Default: Current Time (UNIX_E) + OPTIONAL submission: bool - Whether this is a "submission" or a "now playing" notification. Default: True + """ + def scrobble(self, id: str, scrobble_time: int | None = None, submission: bool | None = True) -> None: + url = self.base_url + '/rest/scrobble?' + + payload = self._getBasePayload() + payload['id'] = id + + if scrobble_time is None: + payload['time'] = round(time.time() * 1000) + elif scrobble_time: + payload['time'] = scrobble_time + + payload['submission'] = submission + + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() + + """ + https://opensubsonic.netlify.app/docs/endpoints/getrandomsongs/ + """ + def getRandomSongs(self, + size: int | None = 10, + genre: str | None = None, + fromYear: str | int | None = None, + toYear: str | int | None = None, + musicFolderId: str | int | None = None + ) -> list[Song]: + url = self.base_url + '/rest/getRandomSongs?' + + payload = self._getBasePayload() + + # The default being 10 is only to make the method match the internal docs + if size and size != 10: + payload['size'] = size + + if genre: + payload['genre'] = genre + + if fromYear: + payload['fromYear'] = fromYear + + if toYear: + payload['toYear'] = toYear + + if musicFolderId: + payload['musicFolderId'] = musicFolderId + + response = requests.get(url + self._encodePayload(payload)).json()['subsonic-response'] + + if response['status'] != 'ok': + raise BadSubsonicResponse() + + random_songs: list[Song] = [] + + for song in response['randomSongs']['song']: + random_songs.append(Song(song)) + + return random_songs - root = xml.etree.ElementTree.fromstring(xml_data) - for item in root.iter(): - if item.tag == '{http://subsonic.org/restapi}song': - return Song(dict(item.items())) diff --git a/app.py b/app.py new file mode 100644 index 0000000..fcfc654 --- /dev/null +++ b/app.py @@ -0,0 +1,1243 @@ +import multiprocessing +import configparser +import logging +import asyncio +import random +import math +import os +import re +import io + +LOG_FILE = 'textual.log' +LOGGING_LEVEL = logging.DEBUG +LOG_FORMAT = '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' + +# Read config file +config = configparser.ConfigParser() +config.read('config.ini') + +CACHE = config.getboolean('STREAMING', 'Cache') +BITRATE = config.get('STREAMING', 'Bitrate') +BITRATE = re.findall(r'[0-9]+', BITRATE)[0] +BITRATE = int(BITRATE) if BITRATE else BITRATE +IMAGE_MODE = config.getboolean('GENERAL', 'GUI') + +import SubSonic + +os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" +import pygame +import pynput + +from rich.text import Text + +from textual import on, events, work +from textual.binding import Binding +from textual.reactive import reactive +from textual.app import App, ComposeResult, RenderResult +from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule +from textual.widget import Widget +from textual.containers import Horizontal, Vertical, Container +from textual.screen import Screen +from textual.widgets.option_list import Option +from textual.timer import Timer + +from rich_pixels import Pixels +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = True + +u, p = [s.strip() for s in open('auth', 'r').read().split(',')] + +conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net") + +pygame.mixer.init() + +class AlbumSongs(Screen): + BINDINGS = [ + ('escape', 'die()', "Back"), + ('p', 'play()', 'Play Song'), + ('q' 'queue()', 'Queue Song'), + ('m', 'shuffle_mix()', 'Shuffle Play') + ] + + 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") + 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:] + app.update_queue_table() + + 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 + app.update_queue_table() + +class ArtistAlbums(Screen): + BINDINGS = [ + ('escape', 'die()', "Back") + ] + + def __init__(self, artist_id: str): + self.artist_id = artist_id + super().__init__() + + def compose(self) -> ComposeResult: + yield DataTable(id="Albums") + yield Footer() + + def on_mount(self) -> None: + albums = conn.getArtistAlbums(self.artist_id) + albumTable: DataTable = self.query_one(DataTable) + + albumTable.cursor_type = 'row' + + albumTable.add_columns("Title", "Songs", "Plays", "Year", "Time") + + for album in albums: + albumTable.add_row(album.title, album.songCount, album.playCount, album.year, album.duration, key=album.id) + + @on(DataTable.RowSelected) + async def album_selected(self, event: DataTable.RowSelected) -> None: + app.install_screen(AlbumSongs(event.row_key.value), "albumSongs") + app.push_screen("albumSongs") + + # Pops and uninstalls self + def action_die(self) -> None: + app.pop_screen() + app.uninstall_screen("artistAlbums") + +class AlbumPanel(Widget, can_focus=True, can_focus_children=True): + + album: SubSonic.Album + + def compose(self) -> ComposeResult: + yield CoverArtPixels(classes="albumArt") + yield Label(classes="albumName") + + def set_album(self, album: SubSonic.Album) -> None: + self.album = album + + coverArt = self.query_one(CoverArtPixels) + coverArt.get_cover_art(self.album.id) + + label = self.query_one(Label) + label.update(self.album.album) + + @on(events.Click) + def clicked(self, event: events.Click) -> None: + app.install_screen(AlbumSongs(self.album.id), 'albumSongs') + app.push_screen('albumSongs') + + def clear(self) -> None: + self.album = None + + coverArt = self.query_one(CoverArtPixels) + coverArt.cover = None + + label = self.query_one(Label) + label.update('') + +class ArtistAlbumsGUI(Screen): + BINDINGS = [ + ('escape', 'die()', "Back"), + Binding('a', 'page_backward()', 'Prev. Page'), + Binding('d', 'page_forward()', 'Next Page') + ] + + artist: SubSonic.Artist + albums: list[SubSonic.Album] + page: int = 1 + pages: int = 1 + albums_displayed: int + + def __init__(self, artist: SubSonic.Artist): + self.artist = artist + super().__init__() + + def compose(self) -> ComposeResult: + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + yield Label(classes='artistNameHeader') + yield Rule() + with Horizontal(): + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + with Horizontal(): + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield Label(id='page-counter') + yield Footer() + + def on_mount(self) -> None: + self.albums = conn.getArtistAlbums(self.artist.id) + album_panels = self.query(AlbumPanel) + + artist_header = self.query_one(Label) + artist_header.update(self.artist.name) + + self.pages = math.ceil(len(self.albums) / len(album_panels)) + self.update_pager() + + self.albums_displayed = 0 + for album_panel in album_panels: + album_panel.set_album(self.albums[self.albums_displayed]) + self.albums_displayed += 1 + + if self.albums_displayed >= len(self.albums): + return + + def update_pager(self) -> None: + pager = self.query_one('#page-counter', Label) + pager.update(f'{self.page} ... {self.pages}') + + def _clear_panels(self) -> None: + album_panels = self.query(AlbumPanel) + + for panel in album_panels: + panel.clear() + + def action_die(self) -> None: + app.pop_screen() + app.uninstall_screen("artistAlbums") + + def action_page_forward(self) -> None: + album_panels = self.query(AlbumPanel) + + if self.page + 1 > self.pages: + return + + self.page += 1 + self.update_pager() + self._clear_panels() + + for album_panel in album_panels: + if self.albums_displayed >= len(self.albums): + return + + album_panel.set_album(self.albums[self.albums_displayed]) + self.albums_displayed += 1 + + def action_page_backward(self) -> None: + album_panels = self.query(AlbumPanel) + + if self.page - 1 <= 0: + return + + # count how many panels actually are displaying an album + real_displayed = 0 + for album_panel in album_panels: + if album_panel.album: + real_displayed += 1 + + self.page -= 1 + self.update_pager() + self._clear_panels() + + self.albums_displayed -= len(album_panels) + real_displayed + for album_panel in album_panels: + if self.albums_displayed >= len(self.albums): + return + + album_panel.set_album(self.albums[self.albums_displayed]) + self.albums_displayed += 1 + +class Artists(Screen): + + artists: list[SubSonic.Artist] + + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + with Vertical(id="artistContainer"): + yield Input(placeholder="Search") + yield OptionList(id="Artists") + yield Footer() + + def on_mount(self) -> None: + app.sub_title = 'Artists' + self.artists = conn.getArtists() + artistList = self.query_one(OptionList) + + for artist in self.artists: + artistList.add_option(Option(artist.name, artist.id)) + + @on(OptionList.OptionSelected) + def artist_selected(self, event: OptionList.OptionSelected) -> None: + app.install_screen(ArtistAlbums(event.option_id), 'artistAlbums') + app.push_screen('artistAlbums') + + @on(Input.Submitted) + def search_submit(self, event: Input.Submitted) -> None: + search_value = event.value.lower() + + artist_list = self.query_one(OptionList) + + artist_list.clear_options() + for artist in self.artists: + if search_value in artist.name.lower(): + artist_list.add_option(Option(artist.name, artist.id)) + +class ArtistLabel(Label, can_focus=True): + + def render(self) -> RenderResult: + if self.has_focus: + return Text(str(self.content), style="white on blue") + else: + return self.content + +class ArtistArt(Static): + + art = reactive[Image.Image | None](None) + + def render(self) -> RenderResult: + if self.art: + width, height = self.size + + height *= 2 + + if width > height: + width = height + elif height > width: + height = width + + try: + return Pixels.from_image(self.art, resize=(width, height)) + except Exception as e: + logging.error(f'Couldn\'t load image {e}') + + return '' + + @work(exclusive=True, thread=True) + def get_artist_image(self, artist_id: str) -> None: + artist_id = 'ar-' + artist_id + + image = None + cache_path = f".cache/art/artists/{artist_id}.png" + + if CACHE and os.path.exists(cache_path): + try: + self.art = Image.open(cache_path) + except Exception as e: + logging.error(f"Failed to load cached artist art {cache_path}: {e}") + + if image is None: + try: + artist_art_data = conn.getCoverArtRaw(artist_id, size=300) + image = Image.open(io.BytesIO(artist_art_data)) + if CACHE: + try: + image.save(cache_path, 'PNG') + except Exception as e: + logging.error(f"Failed to save artist art to cache {cache_path}: {e}") + self.art = image + except Exception as e: + logging.error(f"Failed to get artist art: {e}") + self.art = None + +class ArtistPanel(Widget, can_focus=True, can_focus_children=True): + + artist: SubSonic.Artist | None + + def compose(self) -> ComposeResult: + yield ArtistArt(classes="artistArt") + yield ArtistLabel(classes="artistName") + + def set_artist(self, artist: SubSonic.Artist) -> None: + self.artist = artist + + art: ArtistArt = self.query_one(ArtistArt) + art.get_artist_image(self.artist.id) + + label = self.query_one(Label) + label.update(self.artist.name) + + @on(events.Click) + def clicked(self, event: events.Click) -> None: + if not self.artist: + return + + logging.debug(f'ArtistPanel {self.artist.name} was clicked.') + app.install_screen(ArtistAlbumsGUI(self.artist), 'artistAlbums') + app.push_screen('artistAlbums') + + def clear(self) -> None: + self.artist = None + + art: ArtistArt = self.query_one(ArtistArt) + art.art = None + + label = self.query_one(Label) + label.update('') + +class ArtistsGUI(Screen): + + BINDINGS = [ + Binding('a', "page_backward()", "Prev. Page"), + Binding('d', "page_forward()", "Next Page") + ] + + artists: list[SubSonic.Artist] + artists_displayed: int = 0 + page: int = 1 + pages: int = 1 + + 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("Favourites", 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") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield Label(id="page-counter") + yield Footer() + + def on_mount(self) -> None: + app.sub_title = 'Artists' + self.artists = conn.getArtists() + artist_panels = self.query(ArtistPanel) + + self.artists_displayed = 0 + + self.pages = math.ceil(len(self.artists)/len(artist_panels)) + self.update_pager() + + for artist_panel in artist_panels: + if self.artists_displayed >= len(self.artists) - 1: + return + + artist_panel.set_artist(self.artists[self.artists_displayed]) + self.artists_displayed += 1 + + def update_pager(self) -> None: + pager = self.query_one("#page-counter", Label) + pager.update(f"{self.page} ... {self.pages}") + + def action_page_forward(self) -> None: + artist_panels = self.query(ArtistPanel) + + if self.page + 1 > self.pages: + return + + self.page += 1 + self.update_pager() + self._clear_panels() + + for artist_panel in artist_panels: + if self.artists_displayed >= len(self.artists) - 1: + return + + artist_panel.set_artist(self.artists[self.artists_displayed]) + self.artists_displayed += 1 + + def action_page_backward(self) -> None: + artist_panels = self.query(ArtistPanel) + + if self.page - 1 <= 0: + return + + # Count artist panels with info in them + real_displayed = 0 + for artist_panel in artist_panels: + if artist_panel.artist: + real_displayed += 1 + + self.page -= 1 + self.update_pager() + self._clear_panels() + + self.artists_displayed -= len(artist_panels) + real_displayed + for artist_panel in artist_panels: + artist_panel.set_artist(self.artists[self.artists_displayed]) + self.artists_displayed += 1 + + def _clear_panels(self) -> None: + artist_panels = self.query(ArtistPanel) + + for artist_panel in artist_panels: + artist_panel.clear() + + @on(Input.Submitted) + def search_submit(self, event: Input.Submitted) -> None: + search_value = event.value.lower() + logging.debug(search_value) + + artist_panels = self.query(ArtistPanel) + + self.artists_displayed = 0 + + filtered_artists = [] + for artist in self.artists: + if search_value in artist.name.lower(): + filtered_artists.append(artist) + + self.page = 1 + self.pages = math.ceil(len(filtered_artists) / len(artist_panels)) + self.update_pager() + self._clear_panels() + + for artist_panel in artist_panels: + if self.artists_displayed >= len(filtered_artists): + return + + artist_panel.set_artist(filtered_artists[self.artists_displayed]) + self.artists_displayed += 1 + + + +class Albums(Screen): + + albums: list[SubSonic.Album] + + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + with Vertical(id="albumContainer"): + yield Input(placeholder="Search", id="albumSearchBar") + yield DataTable(id="albumList") + yield Footer() + + def on_mount(self) -> None: + app.sub_title = "Albums" + self.albums = conn.getAllAlbums() + albumTable = self.query_one(DataTable) + + albumTable.cursor_type = 'row' + albumTable.add_columns("Title", "Artist", "Songs", "Plays", "Year", "Time") + + for album in self.albums: + albumTable.add_row(album.title, album.artist, album.songCount, album.playCount, album.year, album.duration, key=album.id) + + @on(DataTable.RowSelected) + def album_selected(self, event: DataTable.RowSelected) -> None: + app.install_screen(AlbumSongs(event.row_key.value), 'albumSongs') + app.push_screen('albumSongs') + + @on(Input.Submitted) + def search_submit(self, event: Input.Submitted) -> None: + search_value = event.value.lower() + + album_table = self.query_one(DataTable) + + album_table.clear() + for album in self.albums: + if search_value in album.title.lower() or search_value in album.artist.lower(): + album_table.add_row(album.title, album.artist, album.songCount, album.playCount, album.year, album.duration, key=album.id) + +class Playlists(Screen): + + playlists: list[SubSonic.Playlist] | None = None + selected_playlist_id: str = None + + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("* F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + app.sub_title = "Playlists" + self.playlists = 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: + app.install_screen(PlaylistSongs(self._find_selected_playlist()), "playlistSongs") + app.push_screen('playlistSongs') + + def _find_selected_playlist(self) -> SubSonic.Playlist: + for playlist in self.playlists: + if playlist.id == self.selected_playlist_id: + return playlist + +class PlaylistSongs(Screen): + BINDINGS = [ + ('escape', 'die()', "Back"), + ('p', 'play()', 'Play Song'), + ('q', 'queue()', 'Queue Song'), + ('m', 'shuffle_mix()', 'Shuffle Play') + ] + + 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: + self.sub_title = self.playlist.name + yield Header(icon="") + yield DataTable() + 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:] + app.update_queue_table() + + 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 + app.update_queue_table() + +class Favourites(Screen): + BINDINGS = [ + ('p', 'play()', 'Play Song'), + ('q', 'queue()', 'Queue Song'), + ('m', 'shuffle_mix()', 'Shuffle Play') + ] + + songs: list[SubSonic.Song] + selected_song_id: str | None = None + + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + yield DataTable() + yield Footer() + + def on_mount(self) -> None: + app.sub_title = "Favourites" + self.songs = conn.getStarredSongs() + songTable = self.query_one(DataTable) + + songTable.cursor_type = "row" + songTable.add_columns("Title", "Album", "Artist", "Plays", "Duration", "Bitrate") + + for song in self.songs: + songTable.add_row(song.title, song.album, song.artist, song.playCount, 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:] + app.update_queue_table() + + def action_queue(self) -> None: + song_index = self._find_selected_song() + app.queue_song(self.songs[song_index]) + + # 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 + app.update_queue_table() + +class CoverArtPixels(Container): + + cover = reactive[Image.Image | None](None) + + def render(self) -> RenderResult: + if self.cover: + width, height = self.size + + height *= 2 + + if width > height: + width = height + elif height > width: + height = width + + return Pixels.from_image(self.cover, resize=(width, height)) + + return '' + + @work(exclusive=True, thread=True) + def get_cover_art(self, cover_art_id: str) -> None: + image = None + cache_path = f".cache/art/{cover_art_id}.png" + + if CACHE and os.path.exists(cache_path): + try: + self.cover = Image.open(cache_path) + except Exception as e: + logging.error(f"Failed to load cached cover art {cache_path}: {e}") + + if image is None: + try: + cover_art_data = conn.getCoverArtRaw(cover_art_id, size=300) + image = Image.open(io.BytesIO(cover_art_data)) + if CACHE: + try: + image.save(cache_path, "PNG") + except Exception as e: + logging.error(f"Failed to save cover art to cache {cache_path}: {e}") + self.cover = image + except Exception as e: + logging.error(f"Failed to get cover art: {e}") + self.cover = None + +class QueueTable(Widget): + + BINDINGS = [ + ("p", "play_queue_song()", "Play"), + ("f", "star_song()", "Star Song") + ] + + song_ids: list[str] = [] + selected_row_key: int = 0 + + def compose(self) -> ComposeResult: + yield DataTable(id="songsqueue", cursor_type='row') + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Title", "Album", "Artist", "Plays", "Duration") + self.update_queue() + + def update_queue(self) -> None: + table = self.query_one(DataTable) + table.clear() + queue: list[SubSonic.Song] = app.user_queue.copy() + queue.extend(app.song_queue) + + self.song_ids = [] + for i, song in enumerate(queue): + table.add_row(song.title, song.album, song.artist, str(song.playCount), str(song.duration), key=str(i)) + self.song_ids.append(song.id) + + @on(DataTable.RowHighlighted) + def set_selected_row_key(self, e: DataTable.RowHighlighted) -> None: + if e.row_key.value: + self.selected_row_key = int(e.row_key.value) + + def action_play_queue_song(self) -> None: + # Reconstruct the queue to get the selected song object + queue: list[SubSonic.Song] = app.user_queue.copy() + queue.extend(app.song_queue) + + if self.selected_row_key >= len(queue): + return # Avoid index out of bounds + + # Add currently playing song to history + if app.currently_playing: + app.play_history.append(app.currently_playing) + + # Add all songs before the selected one to history + songs_to_skip = queue[:self.selected_row_key] + app.play_history.extend(songs_to_skip) + + song_to_play = queue[self.selected_row_key] + + # The new queue is everything after the selected song + new_queue = queue[self.selected_row_key + 1:] + + # Play the song + app.play_song(song_to_play) + + # The user queue is cleared, and the song queue is the rest of the songs + app.user_queue = [] + app.song_queue = new_queue + + # Update the visual queue table + self.update_queue() + + async def action_star_song(self) -> None: + queue: list[SubSonic.Song] = app.user_queue.copy() + queue.extend(app.song_queue) + + song = queue[self.selected_row_key] + + asyncio.create_task(asyncio.to_thread(conn.star, song.id)) + +# Fine to be a static renderable because it's not big enough to need a scroll +class NowPlayingInfo(Container): + song = reactive[SubSonic.Song | None](None, layout=True) + + # Place is from pygame.mixer.music.get_pos and is in ms + place: reactive[int] = reactive(0) + + def render(self) -> RenderResult: + if self.song: + if self.song.title and self.song.artist: + + # Convert seconds to MM:SS + duration_min, duration_sec = divmod(self.song.duration, 60) + duration_formatted = f"{duration_min:02d}:{duration_sec:02d}" + + place_in_seconds = self.place // 1000 + place_min, place_sec = divmod(place_in_seconds, 60) + place_formatted = f"{place_min:02d}:{place_sec:02d}" + + s = f"Song: {self.song.title}\nAlbum: {self.song.album}\nArtist: {self.song.artist}\nProgress: {place_formatted} / {duration_formatted}\n" + + return s + return "Nothing Playing" + +class NowPlaying(Widget): + def compose(self) -> ComposeResult: + yield NowPlayingInfo(id="textinfo") + yield CoverArtPixels(id="coverart") + + def update_song(self, song: SubSonic.Song) -> None: + info = self.query_one(NowPlayingInfo) + info.song = song + + if song.albumId: + cover_art = self.query_one(CoverArtPixels) + + album = conn.getAlbumSongs(song.albumId) + if album: + cover_id: str | None = album[0].coverArtId + if cover_id: + cover_art.get_cover_art(cover_id) + + def set_place(self, place: int) -> None: + info = self.query_one(NowPlayingInfo) + info.place = place + +# https://textual.textualize.io/widgets/data_table/#labeled-rows +# Add this ^ to queue sometime plz +class MainView(Screen): + CSS_PATH = 'main.tcss' + + 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("Favourites", style="white")), classes="headerb") + yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + with Horizontal(): + with Vertical(classes="info", id='info'): + yield NowPlaying(id="now-playing") + # yield ProgressBar() + with Vertical(classes="column"): + yield Label("Queue") + yield QueueTable(id="queue") + yield Footer() + + def on_mount(self) -> None: + app.sub_title = "Overview" + + @on(events.ScreenResume) + def screen_resumed(self, event: events.ScreenResume) -> None: + app.sub_title = "Overview" + queue_table = self.query_one(QueueTable) + queue_table.update_queue() + app.update_now_playing() + +# Startup keyboard listener for media keys +def listen_media_keys(queue: multiprocessing.Queue): + + media_log_file = 'media_keys.log' + logger = logging.getLogger('media_keys') + logger.setLevel(LOGGING_LEVEL) + formatter = logging.Formatter('[%(asctime)s] %(levelname)s in %(module)s: %(message)s') + handler = logging.FileHandler(media_log_file) + handler.setFormatter(formatter) + logger.addHandler(handler) + + logger.info('media_key logger initialized') + + try: + def on_press(key: pynput.keyboard.Key | pynput.keyboard.KeyCode | None) -> None: + logger.debug(f'Process Key: {key}') + match key: + case pynput.keyboard.Key.media_play_pause: + logger.info('Media play/pause key pressed') + queue.put('play_pause') + case pynput.keyboard.Key.media_next: + logger.info('Media next key pressed') + queue.put('next') + case pynput.keyboard.Key.media_previous: + logger.info('Media rewind pressed') + queue.put('prev') + case pynput.keyboard.Key.media_volume_down: + logger.info('Media volume down') + queue.put('vdown') + case pynput.keyboard.Key.media_volume_up: + logger.info('Media volume up') + queue.put('udown') + with pynput.keyboard.Listener(on_press=on_press) as listener: + logger.info('Media key listener started') + listener.join() + except Exception as e: + pass + logger.error(f'Media key listener failed: {e}') + +class TextualSubpyPlayer(App): + BINDINGS = [ + ("r", "rewind()", "⏪"), + ("space", "play_pause_song()", "⏯"), + ("s", "skip()", "⏭"), + ("d", "volume_down()", "🕩"), + ("u", "volume_up()", "🕪"), + ("F", "star_c_song()", "Star Current Song"), + Binding('f1', "switch_mode('main')", 'Overview', show=False), + Binding('f2', "switch_mode('artists')", "Artists", show=False), + Binding('f3', "switch_mode('albums')", "Albums", show=False), + Binding('f4', "switch_mode('favourites')", "Favourites", show=False), + Binding('f5', "switch_mode('playlists')", "Playlists", show=False) + ] + + MODES = { + "main" : MainView, + "artists" : ArtistsGUI, + "albums" : Albums, + "favourites" : Favourites, + "playlists" : Playlists + } + + 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 + place: int = 0 + + music_command_queue: multiprocessing.Queue + + check_playing_timer: Timer | None = None + check_music_commands: Timer + + def on_mount(self) -> None: + self.title = "SubPy" + self.switch_mode('main') + + self.check_music_commands = self.set_interval(2/3, self._music_player_loop, pause=False) + + @work(exclusive=True) + async def play_song(self, song: SubSonic.Song) -> None: + self.currently_playing = song + self.is_paused = False + self.is_playing = True + logging.debug(f"Playing song {song.title} by {song.artist}.") + + self.notify(f"Playing \"{song.title}\" by {song.artist}.") + + audio_data = None + audio_cache_path = f'.cache/audio/{song.id}.ogg' + + if CACHE and os.path.exists(audio_cache_path): + with open(audio_cache_path, 'rb') as file: + audio_data = io.BytesIO(file.read()) + + # If file was found in cache record scrobble + asyncio.create_task(asyncio.to_thread(conn.scrobble, song.id, submission=False)) + else: + # Run the blocking stream call in a separate thread + if BITRATE: + r = await asyncio.to_thread(conn.stream, song.id, maxBitRate=BITRATE) + else: + r = await asyncio.to_thread(conn.stream, song.id) + audio_content = r.content + + if CACHE: + # Also run the file writing in a thread + await asyncio.to_thread(self._cache_audio, audio_cache_path, audio_content) + + audio_data = io.BytesIO(audio_content) + + pygame.mixer.music.load(audio_data) + pygame.mixer.music.play() + + 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) + + self.update_queue_table() + self.update_now_playing() + + # playingLabel: Label = self.query_one(Label) + # playingLabel. + + # Set callback loop for progress bar + # self.progress_timer = self.set_interval(1 / 10, self.update_progress, pause=False) + + def _music_player_loop(self) -> None: + if self.music_command_queue.empty(): + return + + command = self.music_command_queue.get_nowait() + + match command: + case "play_pause": + self.action_play_pause_song() + case "next": + self.action_skip() + case "prev": + self.action_rewind() + case "vdown": + self.action_volume_down() + case "udown": + self.action_volume_up() + + def _cache_audio(self, path: str, content: bytes) -> None: + """Helper method to write audio to cache.""" + with open(path, "wb") as file: + file.write(content) + + def check_done_playing(self) -> None: + if not pygame.mixer.music.get_busy() and not self.is_paused and self.currently_playing: + self.play_history.append(self.currently_playing) + + self.is_played = False + + next_song: SubSonic.Song | None = None + if self.user_queue: + next_song = self.user_queue.pop(0) + elif self.song_queue: + next_song = self.song_queue.pop(0) + + 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.check_playing_timer: + self.check_playing_timer.stop() + + if self.currently_playing: + self.play_history.append(self.currently_playing) + + next_song: SubSonic.Song | None = None + if self.user_queue: + next_song = self.user_queue.pop(0) + elif self.song_queue: + next_song = self.song_queue.pop(0) + + if next_song: + self.play_song(next_song) + + def action_rewind(self) -> None: + if not self.play_history: + return + + if self.check_playing_timer: + self.check_playing_timer.stop() + + if self.currently_playing: + self.song_queue.insert(0, self.currently_playing) + + song_to_play = self.play_history.pop() + self.play_song(song_to_play) + self.update_queue_table() + + def update_progress(self) -> None: + if isinstance(self.screen, MainView) and self.currently_playing: + now_playing = self.screen.query_one(NowPlaying) + now_playing.set_place(pygame.mixer.music.get_pos()) + + def queue_song(self, song: SubSonic.Song) -> None: + logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.') + self.notify(f'Queue\'d song "{song.title}" by {song.artist}.') + self.update_user_queue(song) + + def action_play_pause_song(self) -> None: + self.play_pause_song() + + def action_volume_down(self) -> None: + pygame.mixer.music.set_volume(max(pygame.mixer.music.get_volume() - 0.05, 0.0)) + + def action_volume_up(self) -> None: + pygame.mixer.music.set_volume(min(pygame.mixer.music.get_volume() + 0.05, 1.0)) + + def play_pause_song(self) -> None: + if not self.currently_playing or not self.is_playing: + return None + + if not self.is_paused: + self.notify("Playback paused") + self.place = pygame.mixer.music.get_pos() + pygame.mixer.music.pause() + self.is_paused = True + else: + self.notify("Resuming Playback") + pygame.mixer.music.unpause() + self.is_paused = False + + def update_now_playing(self) -> None: + if isinstance(self.screen, MainView) and self.currently_playing: + logging.debug("Updating now playing widget") + now_playing = self.screen.query_one(NowPlaying) + now_playing.update_song(self.currently_playing) + + def update_queue_table(self) -> None: + if isinstance(self.screen, MainView): + logging.debug("Updating queue through app") + queue_table = self.screen.query_one(QueueTable) + + queue_table.update_queue() + + def update_user_queue(self, song: SubSonic.Song) -> None: + self.user_queue.append(song) + self.update_queue_table() + + def update_song_queue(self, song: SubSonic.Song) -> None: + self.song_queue.append(song) + self.update_queue_table() + + async def action_star_c_song(self) -> None: + if self.currently_playing: + await asyncio.create_task(asyncio.to_thread(conn.star, self.currently_playing.id)) + self.notify(f'Favourite song "{self.currently_playing.title}".') + + def on_key(self, event: events.Key) -> None: + logging.debug(event.key) + +if __name__ == '__main__': + os.makedirs(".cache/art/", exist_ok=True) + os.makedirs(".cache/audio/", exist_ok=True) + os.makedirs(".cache/art/artists/", exist_ok=True) + + # Clear Log File + if os.path.exists(LOG_FILE): + os.remove(LOG_FILE) + + logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT) + + q = multiprocessing.Queue(-1) + p = multiprocessing.Process(target=listen_media_keys, args=(q,)) + p.start() + + app = TextualSubpyPlayer() + app.music_command_queue = q + + app.run() + p.terminate() diff --git a/artist_response.xml b/artist_response.xml deleted file mode 100644 index fa0272e..0000000 --- a/artist_response.xml +++ /dev/null @@ -1,382 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/blue-girl-on-sunday.xml b/blue-girl-on-sunday.xml deleted file mode 100644 index 3073806..0000000 --- a/blue-girl-on-sunday.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..9720842 --- /dev/null +++ b/config.ini @@ -0,0 +1,6 @@ +[STREAMING] +Cache = true +Bitrate = 320kbps + +[GENERAL] +GUI = true diff --git a/main.py b/main.py deleted file mode 100644 index 9fd44d5..0000000 --- a/main.py +++ /dev/null @@ -1,95 +0,0 @@ -import urwid - -# txt = urwid.Text("hello world") -# fill = urwid.Filler(txt, "top") -# loop = urwid.MainLoop(fill) -# loop.run() -import os -import io -from typing import Iterable - -os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" -import pygame - -import SubSonic - -u = 'pseudo' -p = 'telegram9dock-MICHEL' -conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net") - -echidna_id = "937004317800a879f0cddd471fb82387" - -pygame.mixer.init() - - -class SubPyPlayer: - conn: SubSonic.SubpyConn - main: urwid.Padding - - def __init__(self, conn: SubSonic.SubpyConn): - self.conn = conn - self.main = urwid.Padding(self.menu('test', ['Artists', 'Albums', 'Songs', 'Playlists']), left=2, right=2) - top = urwid.Overlay( - self.main, - urwid.SolidFill("\N{MEDIUM SHADE}"), - align=urwid.CENTER, - width=(urwid.RELATIVE, 60), - valign=urwid.MIDDLE, - height=(urwid.RELATIVE, 60), - min_width=20, - min_height=9, - ) - urwid.MainLoop(top, palette=[("reversed", "standout", "")]).run() - - # Currently Blocking - def play_song(self, song_id: str): - r = self.conn.stream(song_id) - - # convert stream to bytes io thing - audio_data = io.BytesIO(r.content) - - pygame.mixer.music.load(audio_data) - pygame.mixer.music.play() - - while pygame.mixer.music.get_busy(): - pygame.time.Clock().tick(10) - - def menu(self, title: str, choices_: Iterable[str]) -> urwid.ListBox: - - body = [urwid.Text(title), urwid.Divider()] - - for c in choices_: - - button = urwid.Button(c) - - urwid.connect_signal(button, "click", self.item_chosen, c) - - body.append(urwid.AttrMap(button, None, focus_map="reversed")) - - return urwid.ListBox(urwid.SimpleFocusListWalker(body)) - - def item_chosen(self, button: urwid.Button, choice: str) -> None: - - response = urwid.Text(["You chose ", choice, "\n"]) - - done = urwid.Button("Ok") - - urwid.connect_signal(done, "click", self.exit_program) - - self.main.original_widget = urwid.Filler( - urwid.Pile( - [ - response, - urwid.AttrMap(done, None, focus_map="reversed"), - ] - ) - ) - - - def exit_program(self, button: urwid.Button) -> None: - raise urwid.ExitMainLoop() - -player = SubPyPlayer(conn) - -# player.play_song(echidna_id) - diff --git a/main.tcss b/main.tcss new file mode 100644 index 0000000..dc79ffd --- /dev/null +++ b/main.tcss @@ -0,0 +1,89 @@ +.info { + width: 30%; + height: 100%; +} + +#nowplaying { + height: 100%; +} + +#textinfo { + height: 6; +} + +#coverart { + width: 100%; + height: 100%; +} + +#albumContainer { + height: 100%; +} + +#artistContainer { + height: 100%; +} + +.headerb { + width: 20%; + height: 1; + background: #2c2e30; + text-align: center; +} + +#headerbs { + height: 1; +} + +.artistPanel { + width: 20%; + height: 100%; + padding: 0 1; +} + +.artistArt { + height: 1fr; + content-align: center middle; +} + +.artistName { + width: 100%; + text-align: center; +} + +.artistInfo { + width: 1fr; + height: 1.25fr; +} + +.artistInfoArt { + width: 1fr; + height: 100%; + content-align: center middle; +} + +.artistInfoTextbox { + width: 4fr; +} + +.albumPanel { + width: 1fr; + height: 1fr; + padding: 0 1; +} + +.albumArt { + height: 1fr; + content-align: center middle; +} + +.albumName { + text-wrap: wrap; + text-align: center; + width: 100%; +} + +.artistNameHeader { + width: 100%; + text-align: center; +} diff --git a/random_test_code.py b/random_test_code.py deleted file mode 100644 index 788d891..0000000 --- a/random_test_code.py +++ /dev/null @@ -1,89 +0,0 @@ -# conn = SubpyConn(u, p, "https://music.0x01fe.net") - - - -# artistIndexes = conn.getIndexes() -# for artistIndex in artistIndexes.indexes: -# print(artistIndex.name, artistIndex.artists) - -helios_id = 'c69cdc593487ed813f1e11ecb270b228' -yakui_id = "7f6ca9951af294420382442061a2d6ee" -# r = conn.getMusicDirectory(helios_id) -# with open("getHelios.xml", 'wb') as file: -# file.write(r.content) - -# yakui_albums = conn.getArtistAlbums(yakui_id) - -# for album in yakui_albums: -# print(album.name) - -# helios_songs = conn.getAlbumSongs(helios_id) -# for song in helios_songs: -# print(song.trackNumber, song.title) - -# artists = conn.getArtists() - -# for artist in artists: -# print(artist.name) - -echidna_id = "937004317800a879f0cddd471fb82387" - -# print(conn.stream(echidna_id).content) - - -# def getArtists() -> list[Artist]: -# tree = xml.etree.ElementTree.parse("artist_response.xml") -# root = tree.getroot() - -# artists: list[Artist] = [] - -# if root.get('status') != 'ok': -# return [] - -# for item in root.iter(): -# print(item.tag, dict(item.items())) - -# if item.tag == '{http://subsonic.org/restapi}artist': -# a = Artist() -# a.fromDict(dict(item.items())) - -# artists.append(a) - - - -# return artists - - - -# artists = getArtists() - -# for artist in artists: -# print(artist.name) - - -# conn = libsonic.Connection('https://music.0x01fe.net', 'pseudo', p, port=443) - - -# song = conn.getRandomSongs(1)['randomSongs']['song'][0] -# song_id = song['id'] -# rate = song['samplingRate'] - -# print(song_id) - -# stream = conn.stream(song_id) - -# while not stream.fp: -# pass - - -# pya = pyaudio.PyAudio() -# pya_stream = pya.open(format=) -# wave_obj = simpleaudio.WaveObject(stream.fp) -# play_obj = wave_obj.play() -# play_obj.wait_done() - - - - - - diff --git a/requirements.txt b/requirements.txt index 2f95170..365ff1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -requests==2.31.0 -simpleaudio==1.0.4 -urwid==2.6.16 -py-sonic==1.0.1 -pyTermTk==0.41.0a54 \ No newline at end of file +requests +textual +pygame +Pillow +rich-pixels +pynput diff --git a/responses/artist_response.xml b/responses/artist_response.xml deleted file mode 100644 index fa0272e..0000000 --- a/responses/artist_response.xml +++ /dev/null @@ -1,382 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/responses/echidna.xml b/responses/echidna.xml deleted file mode 100644 index b05c648..0000000 --- a/responses/echidna.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - \ No newline at end of file diff --git a/responses/getHelios.xml b/responses/getHelios.xml deleted file mode 100644 index 6e4bbe7..0000000 --- a/responses/getHelios.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/responses/getIndexes.xml b/responses/getIndexes.xml deleted file mode 100644 index 4f46f50..0000000 --- a/responses/getIndexes.xml +++ /dev/null @@ -1,297 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/responses/getYakui.xml b/responses/getYakui.xml deleted file mode 100644 index c16a748..0000000 --- a/responses/getYakui.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test-termtk.py b/test-termtk.py deleted file mode 100644 index 99c142e..0000000 --- a/test-termtk.py +++ /dev/null @@ -1,99 +0,0 @@ -import TermTk as ttk - -import SubSonic - -u = 'pseudo' -p = 'telegram9dock-MICHEL' - -echidna_id = "937004317800a879f0cddd471fb82387" - -class SubPlayer: - conn: SubSonic.SubpyConn - - artists: list[SubSonic.Artist] - - selected_artist: SubSonic.Artist - selected_album: SubSonic.Album - selected_songs: list[SubSonic.Song] - - def __init__(self, conn: SubSonic.SubpyConn): - self.conn = conn - - def run(self) -> None: - self.artists = conn.getArtists() - self.root = ttk.TTk(layout=ttk.TTkHBoxLayout()) - - self.artistList = ttk.TTkList( - name="Artists", - parent=self.root, - items=[artist.name for artist in self.artists] - ) - - self.albumList = ttk.TTkList( - name="ArtistAlbums", - parent=self.root - ) - - self.songList = ttk.TTkList( - name="AlbumSongs", - parent=self.root - ) - - - self.artistList.itemClicked.connect(self.clickArtist) - self.albumList.itemClicked.connect(self.clickAlbum) - - self.root.mainloop() - - def _clearSongs(self) -> None: - self.songList.removeItems(self.songList.items()) - self.songList.setVisible(False) - self.songList.setEnabled(False) - - def _clearAlbums(self) -> None: - self.albumList.removeItems(self.albumList.items()) - # self.albumList.setVisible(False) - # self.albumList.setEnabled(False) - - @ttk.pyTTkSlot(ttk.TTkWidgets.listwidget.TTkAbstractListItem) - def clickArtist(self, v: ttk.TTkWidgets.listwidget.TTkAbstractListItem): - # ttk.TTkLog.debug('test') - # print(v.text()) - - artist_name = v.text() - for artist in self.artists: - if artist.name == artist_name: - self.selected_artist = artist - - self._clearAlbums() - self._clearSongs() - - # self.albumList.setVisible(True) - # self.albumList.setEnabled(True) - - albums = conn.getArtistAlbums(self.selected_artist.id) - self.albumList.addItems([album.name for album in albums]) - - @ttk.pyTTkSlot(ttk.TTkWidgets.listwidget.TTkAbstractListItem) - def clickAlbum(self, v: ttk.TTkWidgets.listwidget.TTkAbstractListItem): - - album_name = v.text() - - albums = conn.getArtistAlbums(self.selected_artist.id) - for album in albums: - if album.title == album_name: - self.selected_album = album - - # print('b') - - self._clearSongs() - - self.songList.setVisible(True) - self.songList.setEnabled(True) - - self.songList.addItems([song.title for song in conn.getAlbumSongs(self.selected_album.id)]) - - -conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net") -player = SubPlayer(conn) -player.run() diff --git a/test.xml b/test.xml deleted file mode 100644 index c85d69a..0000000 --- a/test.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/textual-test.py b/textual-test.py deleted file mode 100644 index 1d60380..0000000 --- a/textual-test.py +++ /dev/null @@ -1,183 +0,0 @@ -import logging -import os - -from enum import Enum - -LOG_FILE = 'textual.log' -LOGGING_LEVEL = logging.DEBUG -LOG_FORMAT = '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' - -# Clear Log File -os.remove(LOG_FILE) - -logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT) - -import SubSonic - - -from textual import on, events -from textual.app import App, ComposeResult -from textual.widgets import Footer, Header, OptionList, DataTable -from textual.widgets.option_list import Option - -u = 'pseudo' -p = 'telegram9dock-MICHEL' - -conn = SubSonic.SubpyConn(u, p, "music.0x01fe.net") - -class Menus(Enum): - MAIN = "MAIN" - ARTISTS = "Artists" - ARTISTSONGS = "ArtistSongs" - ARTISTALBUMS = "ArtistAlbums" - ALBUMS = "Albums" - ALBUMSONGS = "AlbumSongs" - -class TextualSubpyPlayer(App): - - BINDINGS = [ - ('backspace', 'menu_back()', 'Go back a menu') - ] - - artists: list[SubSonic.Artist] - selectedArtist: SubSonic.Artist - albums: list[SubSonic.Album] - conn: SubSonic.SubpyConn - - currentMenu: Menus - lastMenu: Menus - - def compose(self) -> ComposeResult: - self.currentMenu = Menus.MAIN - self.lastMenu = Menus.MAIN - yield OptionList( - Option("Browse Artists", id="BrowseArtists"), - Option("Browse Albums"), - Option("Browse Songs"), - Option("Browse Playlists"), - id=Menus.MAIN.value - ) - - async def action_menu_back(self) -> None: - logging.debug('Menu Back Called') - match self.lastMenu: - case Menus.MAIN: - logging.debug("Moving back to the main menu") - await self.main_menu() - case Menus.ARTISTALBUMS: - logging.debug("Moving back to Artist Albums") - await self.selectArtistAlbumView(self.selectedArtist.id) - case Menus.ARTISTS: - logging.debug("Moving backt to artists") - await self.selectArtistsView() - case _: - logging.debug(f"Last Menu unknown, {self.lastMenu}") - - def on_key(self, event: events.Key) -> None: - logging.debug(f'Key Pressed {event.name}') - - - def action_toggle_dark(self) -> None: - """An action to toggle dark mode.""" - self.theme = ( - "textual-dark" if self.theme == "textual-light" else "textual-light" - ) - - - async def _getArtistMenu(self) -> OptionList: - self.artists = self.conn.getArtists() - - return OptionList(id=Menus.ARTISTS.value) - - - @on(OptionList.OptionSelected, f'#{Menus.MAIN.value}') - async def selectMainMenu(self, event: OptionList.OptionSelected) -> None: - match event.option_id: - case "BrowseArtists": - logging.debug('View Artists Selected') - await self.selectArtistsView() - - - @on(OptionList.OptionSelected, f"#{Menus.ARTISTS.value}") - async def artistSelected(self, event: OptionList.OptionSelected) -> None: - logging.debug(f'Artist {event.option.prompt} Selected') - self.lastMenu = Menus.ARTISTS - - for artist in self.artists: - if event.option_id == artist.id: - self.selectedArtist = artist - - await self.selectArtistAlbumView(self.selectedArtist.id) - - - @on(DataTable.RowSelected, "#ArtistAlbums") - async def ArtistAlbumSelected(self, event: DataTable.RowSelected) -> None: - self.lastMenu = Menus.ARTISTALBUMS - row_key = event.row_key - row = event.data_table.get_row(row_key) - logging.debug(f'Album {row[0]} Selected') - - await self.selectAlbumSongsView(row_key.value) - - async def selectAlbumSongsView(self, album_id: str) -> None: - logging.debug("Setting up album songs DataTable") - self.currentMenu = Menus.ALBUMSONGS - - songs = conn.getAlbumSongs(album_id) - albumSongs = DataTable(id="AlbumSongs") - - await self.mount(albumSongs) - - albumSongs.cursor_type = 'row' - albumSongs.add_columns("#", "Title", "Artist", "Duration", "Bitrate") - - for song in songs: - albumSongs.add_row(song.trackNumber, song.title, song.artist, song.duration, song.bitRate, key=song.id) - - self.query_one(f"#{self.lastMenu.value}").remove() - - async def selectArtistAlbumView(self, artist_id: str) -> None: - logging.debug("Setting up album DataTable") - self.currentMenu = Menus.ARTISTALBUMS - - albumTable = DataTable(id="ArtistAlbums") - - await self.mount(albumTable) - - albumTable.cursor_type = 'row' - - albumTable.add_columns("Title", "Songs") - - self.albums = conn.getArtistAlbums(artist_id) - for album in self.albums: - albumTable.add_row(album.title, album.songCount, key=album.id) - - self.query_one(f"#{self.lastMenu.value}").remove() - - async def main_menu(self) -> None: - await self.mount(OptionList( - Option("Browse Artists", id="BrowseArtists"), - Option("Browse Albums"), - Option("Browse Songs"), - Option("Browse Playlists"), - id=Menus.MAIN.value - )) - - self.query_one(f"#{self.currentMenu.value}").remove() - - async def selectArtistsView(self) -> None: - artistList: OptionList = await self._getArtistMenu() - - self.currentMenu = Menus.ARTISTS - - await self.mount(artistList) - - for artist in self.artists: - artistList.add_option(Option(artist.name, artist.id)) - - self.query_one(f"#{self.lastMenu.value}").remove() - -if __name__ == '__main__': - app = TextualSubpyPlayer() - app.conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net") - app.run() diff --git a/textual.log b/textual.log deleted file mode 100644 index c778d98..0000000 --- a/textual.log +++ /dev/null @@ -1,71 +0,0 @@ -[2025-01-21 09:35:07,989] DEBUG in selector_events: Using selector: EpollSelector -[2025-01-21 09:35:08,547] DEBUG in textual-test: Key Pressed enter -[2025-01-21 09:35:08,548] DEBUG in textual-test: View Artists Selected -[2025-01-21 09:35:08,550] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:08,732] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getIndexes?u=pseudo&t=a97d230d3166610b05dd3a30bb673f32&s=AOT38T3U&v=1.16.1&c=testing HTTP/1.1" 200 None -[2025-01-21 09:35:09,360] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:09,601] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:09,775] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:10,338] DEBUG in textual-test: Key Pressed backspace -[2025-01-21 09:35:10,339] DEBUG in textual-test: Menu Back Called -[2025-01-21 09:35:10,339] DEBUG in textual-test: Moving back to the main menu -[2025-01-21 09:35:11,388] DEBUG in textual-test: Key Pressed enter -[2025-01-21 09:35:11,389] DEBUG in textual-test: View Artists Selected -[2025-01-21 09:35:11,399] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:11,572] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getIndexes?u=pseudo&t=a28d65a7ebd5359e9c2ec2b8fa30478b&s=ZWOC8KFV&v=1.16.1&c=testing HTTP/1.1" 200 None -[2025-01-21 09:35:12,120] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,620] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,656] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,680] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,711] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,742] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,772] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,803] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,834] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,864] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,895] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,926] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:12,956] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:13,209] DEBUG in textual-test: Key Pressed backspace -[2025-01-21 09:35:13,210] DEBUG in textual-test: Menu Back Called -[2025-01-21 09:35:13,210] DEBUG in textual-test: Moving back to the main menu -[2025-01-21 09:35:13,779] DEBUG in textual-test: Key Pressed enter -[2025-01-21 09:35:13,780] DEBUG in textual-test: View Artists Selected -[2025-01-21 09:35:13,781] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:13,968] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getIndexes?u=pseudo&t=2931ef5d69cd6d656d7f9c20b2968f1b&s=RYEHVX5N&v=1.16.1&c=testing HTTP/1.1" 200 None -[2025-01-21 09:35:14,284] DEBUG in textual-test: Key Pressed backspace -[2025-01-21 09:35:14,284] DEBUG in textual-test: Menu Back Called -[2025-01-21 09:35:14,284] DEBUG in textual-test: Moving back to the main menu -[2025-01-21 09:35:15,494] DEBUG in textual-test: Key Pressed enter -[2025-01-21 09:35:15,495] DEBUG in textual-test: View Artists Selected -[2025-01-21 09:35:15,496] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:15,678] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getIndexes?u=pseudo&t=0463fa3d1d9c1537d395eb7fc64a8b24&s=IMVSYTQU&v=1.16.1&c=testing HTTP/1.1" 200 None -[2025-01-21 09:35:15,792] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,291] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,322] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,353] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,383] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,413] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,444] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,474] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,505] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,542] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,565] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,596] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,626] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:16,911] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:17,093] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:17,252] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:17,663] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:17,950] DEBUG in textual-test: Key Pressed enter -[2025-01-21 09:35:17,951] DEBUG in textual-test: Artist Daft Punk Selected -[2025-01-21 09:35:17,951] DEBUG in textual-test: Setting up album DataTable -[2025-01-21 09:35:17,957] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:18,134] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getMusicDirectory?u=pseudo&t=306fc4f8690b4c90a4502e59d3e985f1&s=GKUV4BSR&v=1.16.1&c=testing&id=39bfe262546e5598284a148b1eb59d4a HTTP/1.1" 200 816 -[2025-01-21 09:35:19,063] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:19,223] DEBUG in textual-test: Key Pressed down -[2025-01-21 09:35:19,805] DEBUG in textual-test: Key Pressed backspace -[2025-01-21 09:35:19,805] DEBUG in textual-test: Menu Back Called -[2025-01-21 09:35:19,805] DEBUG in textual-test: Moving backt to artists -[2025-01-21 09:35:19,807] DEBUG in connectionpool: Starting new HTTPS connection (1): music.0x01fe.net:443 -[2025-01-21 09:35:19,989] DEBUG in connectionpool: https://music.0x01fe.net:443 "GET /rest/getIndexes?u=pseudo&t=151c6db8f37e0b900d3ca7c6384de338&s=SPHUFUL3&v=1.16.1&c=testing HTTP/1.1" 200 None