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
+
+
+
+Artists Page
+
+](.attachments/artists-menu.png)
+
+
+
+
+View of favorite songs
+
+
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