New SongView, UV & More (#6)
Honestly, I don't remember everything I've done in this branch. Important things: - Started using UV for dependency / python version management - Fixed the stupid queue skipping bug (still can't believe it was a race condition) - New SongView widget that multiple screens share - New features on most views with songs! - Songs can be shared - Songs can be added to playlists - Songs can be rated - Upgraded my Navidrome server version & had to change some stuff for it Co-authored-by: Jackson Hodge <jackson.d.hodge@jbhunt.com> Reviewed-on: #6
This commit is contained in:
1 parent
d3de6ee914
commit
2295af7b56
10 files changed
+763
-201
No files matched your search
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
__pycache__
|
__pycache__/
|
||||||
.cache/
|
.cache/
|
||||||
|
examples/
|
||||||
|
.venv/
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
auth
|
auth
|
||||||
|
test.py
|
||||||
|
|
||||||
+24
-3
@@ -1,4 +1,5 @@
|
|||||||
import requests
|
import requests
|
||||||
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import SubSonic
|
import SubSonic
|
||||||
@@ -7,7 +8,7 @@ import SubSonic
|
|||||||
"""
|
"""
|
||||||
Navidrome has some API endpoints that are not included in the SubSonic or OpenSubSonic standards.
|
Navidrome has some API endpoints that are not included in the SubSonic or OpenSubSonic standards.
|
||||||
Nor are they documented anywhere I can find, the NavidromeAPI class is a collection of methods to interact with this hidden API.
|
Nor are they documented anywhere I can find, the NavidromeAPI class is a collection of methods to interact with this hidden API.
|
||||||
The endpoints are reverse engineered from observing requests made by the Navidrome WebUI.
|
The endpoints are reverse engineered from observing requests made by the Navidrome server webUI.
|
||||||
"""
|
"""
|
||||||
class NavidromeAPI:
|
class NavidromeAPI:
|
||||||
username: str
|
username: str
|
||||||
@@ -53,7 +54,6 @@ class NavidromeAPI:
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
|
response = requests.get(url + self._encodePayload(payload), headers=self.auth_header)
|
||||||
print(len(response.content))
|
|
||||||
|
|
||||||
return [SubSonic.Song(song_d) for song_d in response.json()]
|
return [SubSonic.Song(song_d) for song_d in response.json()]
|
||||||
|
|
||||||
@@ -94,4 +94,25 @@ class NavidromeAPI:
|
|||||||
def isSongStarred(self, id: str) -> bool:
|
def isSongStarred(self, id: str) -> bool:
|
||||||
info = self.getSong(id)
|
info = self.getSong(id)
|
||||||
|
|
||||||
return info[0]['starred']
|
logging.info(info)
|
||||||
|
|
||||||
|
return info[0].get('starred') if info[0].get('starred') else False
|
||||||
|
|
||||||
|
"""
|
||||||
|
A strange one. Used for reordering tracks on a playlist, since it's not a native SubSonic thing.
|
||||||
|
playlist_id (str): Id of the playlist you're updating
|
||||||
|
song_index (int): Track you want to move
|
||||||
|
insert_before_index (int): For example, if you want to move a track to the start of a playlist, this is 1.
|
||||||
|
|
||||||
|
RETURNS: index of the editting song (song_index)
|
||||||
|
"""
|
||||||
|
def playlistInsertTrackBefore(self, playlist_id: str, song_index: int, insert_before_index: int) -> int:
|
||||||
|
url = self.base_url + f'/api/playlist/{playlist_id}/tracks/{song_index}'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"insert_before" : str(insert_before_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=self.auth_header, json=payload)
|
||||||
|
|
||||||
|
return response['id']
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SubPy (Name pending)
|
# SubPy (Name pending)
|
||||||
|
|
||||||
SubPy is a terminal user interface (TUI) music player written in Python primarly using the Textual library. SubPy connects to a SubSonic server using the SubSonic API to stream music to your machine.
|
SubPy is a terminal user interface (TUI) music player written in Python primarly using the Textual library. SubPy connects to a Navidrome server using its API to stream music to your machine. Currently the player is designed to work with Navidrome 0.60.3.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
|||||||
+121
-25
@@ -38,24 +38,24 @@ class Album:
|
|||||||
songCount: int
|
songCount: int
|
||||||
playCount: int
|
playCount: int
|
||||||
year: int | None
|
year: int | None
|
||||||
duration: str
|
duration_f: str
|
||||||
|
|
||||||
def __init__(self, album_info: dict):
|
def __init__(self, album_info: dict):
|
||||||
self.id = album_info['id']
|
self.id = album_info['id']
|
||||||
self.name = album_info['name']
|
self.name = album_info['name']
|
||||||
self.title = album_info['title'] if 'title' in album_info else self.name
|
self.title = album_info['title'] if 'title' in album_info else self.name
|
||||||
self.album = album_info['album'] if 'album' in album_info else self.name
|
self.album = album_info['album'] if 'album' in album_info else self.name
|
||||||
self.artist = album_info['artist']
|
self.artist = album_info.get('artist')
|
||||||
|
if not self.artist:
|
||||||
|
self.artist = album_info.get('albumArtist')
|
||||||
self.coverArtId = album_info['coverArt'] if 'coverArt' in album_info else None
|
self.coverArtId = album_info['coverArt'] if 'coverArt' in album_info else None
|
||||||
self.duration = int(album_info['duration'])
|
self.duration = int(album_info['duration'])
|
||||||
self.artistId = album_info['artistId']
|
self.artistId = album_info.get('albumArtistId')
|
||||||
self.songCount = int(album_info['songCount'])
|
self.songCount = int(album_info['songCount'])
|
||||||
self.playCount = int(album_info['playCount']) if 'playCount' in album_info else 0
|
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.year = int(album_info['year']) if 'year' in album_info else None
|
||||||
self.duration = self._secondToMinutes(int(album_info['duration']))
|
self.duration_f = self.duration = time.strftime('%H:%M:%S', time.gmtime(int(album_info['duration'])))
|
||||||
|
|
||||||
def _secondToMinutes(self, s: int) -> str:
|
|
||||||
return f'{s // 60}:{s % 60 if s % 60 > 9 else "0" + str(s % 60)}'
|
|
||||||
|
|
||||||
class Song:
|
class Song:
|
||||||
id: str
|
id: str
|
||||||
@@ -76,16 +76,20 @@ class Song:
|
|||||||
samplingRate: int
|
samplingRate: int
|
||||||
playCount: int | None
|
playCount: int | None
|
||||||
coverArtId: str | None
|
coverArtId: str | None
|
||||||
|
# Navidrome Fields
|
||||||
|
rating: int | None
|
||||||
|
|
||||||
def __init__(self, info: dict | None = {}):
|
def __init__(self, info: dict | None = None):
|
||||||
|
if info is None:
|
||||||
|
info = {}
|
||||||
self.id = info['id'] if 'id' in info else ""
|
self.id = info['id'] if 'id' in info else ""
|
||||||
self.title = info['title'] if 'title' in info else ""
|
self.title = info['title'] if 'title' in info else ""
|
||||||
self.album = info['album'] if 'album' in info else ""
|
self.album = info['album'] if 'album' in info else ""
|
||||||
self.artist = info['artist'] if 'artist' in info else ""
|
self.artist = info['artist'] if 'artist' in info else ""
|
||||||
self.trackNumber = int(info['track']) if 'track' in info else ""
|
self.trackNumber = int(info['track']) if 'track' in info else 0
|
||||||
self.contentType = info['contentType'] if 'contentType' in info else ""
|
self.contentType = info['contentType'] if 'contentType' in info else ""
|
||||||
self.suffix = info['suffix'] if 'suffix' in info else ""
|
self.suffix = info['suffix'] if 'suffix' in info else ""
|
||||||
self.duration = int(info['duration']) if 'duration' in info else ""
|
self.duration = int(info['duration']) if 'duration' in info else 0
|
||||||
self.bitRate = int(info['bitRate']) if 'bitRate' in info else ""
|
self.bitRate = int(info['bitRate']) if 'bitRate' in info else ""
|
||||||
self.path = info['path'] if 'path' in info else ""
|
self.path = info['path'] if 'path' in info else ""
|
||||||
self.discNumber = int(info['discNumber']) if 'discNumber' in info else ""
|
self.discNumber = int(info['discNumber']) if 'discNumber' in info else ""
|
||||||
@@ -96,6 +100,7 @@ class Song:
|
|||||||
self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else ""
|
self.samplingRate = int(info['samplingRate']) if 'samplingRate' in info else ""
|
||||||
self.playCount = int(info['playCount']) if 'playCount' in info else ""
|
self.playCount = int(info['playCount']) if 'playCount' in info else ""
|
||||||
self.coverArtId = info['coverArt'] if 'coverArt' in info else None
|
self.coverArtId = info['coverArt'] if 'coverArt' in info else None
|
||||||
|
self.rating = info.get('rating')
|
||||||
|
|
||||||
class Artist:
|
class Artist:
|
||||||
id: str
|
id: str
|
||||||
@@ -130,34 +135,30 @@ class SubpyConn:
|
|||||||
salt: str
|
salt: str
|
||||||
salt_len: int = 8
|
salt_len: int = 8
|
||||||
version: str = "1.16.1"
|
version: str = "1.16.1"
|
||||||
client_name: str = "testing"
|
client_name: str = "SubSonic Python API Lib"
|
||||||
|
|
||||||
def __init__(self, u: str, p: str, base_url: str, salt_len: int | None = 8):
|
def __init__(self, u: str, p: str, base_url: str, salt_len: int | None = 8, client_name: str | None = "SubSonic Python API Lib"):
|
||||||
self.username = u
|
self.username = u
|
||||||
self.password = p
|
self.password = p
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
|
self.client_name = client_name
|
||||||
|
|
||||||
self.salt_len = salt_len
|
self.salt_len = salt_len
|
||||||
|
|
||||||
def _saltGenerator(self) -> None:
|
|
||||||
self.salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len))
|
|
||||||
|
|
||||||
def _hashPass(self) -> str:
|
|
||||||
self._saltGenerator()
|
|
||||||
return hashlib.md5((self.password + self.salt).encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
def _getBasePayload(self) -> dict:
|
def _getBasePayload(self) -> dict:
|
||||||
|
salt = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(self.salt_len))
|
||||||
|
token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest()
|
||||||
return {
|
return {
|
||||||
"u" : self.username,
|
"u": self.username,
|
||||||
"t" : self._hashPass(),
|
"t": token,
|
||||||
"s" : self.salt,
|
"s": salt,
|
||||||
"v" : self.version,
|
"v": self.version,
|
||||||
"c" : self.client_name,
|
"c": self.client_name,
|
||||||
"f": "json"
|
"f": "json"
|
||||||
}
|
}
|
||||||
|
|
||||||
def _encodePayload(self, payload: dict) -> str:
|
def _encodePayload(self, payload: dict) -> str:
|
||||||
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus)
|
return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus, doseq=True)
|
||||||
|
|
||||||
def _getMusicDirectory(self, id: str):
|
def _getMusicDirectory(self, id: str):
|
||||||
url = self.base_url + '/rest/getMusicDirectory?'
|
url = self.base_url + '/rest/getMusicDirectory?'
|
||||||
@@ -265,6 +266,9 @@ class SubpyConn:
|
|||||||
|
|
||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
|
|
||||||
|
if 'entry' not in json_r['subsonic-response']['playlist']:
|
||||||
|
return []
|
||||||
|
|
||||||
songs: list[Song] = []
|
songs: list[Song] = []
|
||||||
for song in json_r['subsonic-response']['playlist']['entry']:
|
for song in json_r['subsonic-response']['playlist']['entry']:
|
||||||
songs.append(Song(song))
|
songs.append(Song(song))
|
||||||
@@ -280,7 +284,7 @@ class SubpyConn:
|
|||||||
json_r = r.json()
|
json_r = r.json()
|
||||||
|
|
||||||
starred: list[Album | Song | Artist] = []
|
starred: list[Album | Song | Artist] = []
|
||||||
for album in json_r['subsonic-response']['starred']['artist']:
|
for album in json_r['subsonic-response']['starred']['album']:
|
||||||
starred.append(Album(album))
|
starred.append(Album(album))
|
||||||
|
|
||||||
for artist in json_r['subsonic-response']['starred']['artist']:
|
for artist in json_r['subsonic-response']['starred']['artist']:
|
||||||
@@ -384,6 +388,21 @@ class SubpyConn:
|
|||||||
if response['status'] != 'ok':
|
if response['status'] != 'ok':
|
||||||
raise BadSubsonicResponse()
|
raise BadSubsonicResponse()
|
||||||
|
|
||||||
|
"""
|
||||||
|
https://opensubsonic.netlify.app/docs/endpoints/setrating/
|
||||||
|
|
||||||
|
id A string which uniquely identifies the file (song) or folder (album/artist) to rate.
|
||||||
|
rating The rating between 1 and 5 (inclusive), or 0 to remove the rating: only accepts ints
|
||||||
|
"""
|
||||||
|
def setRating(self, id: str, rating: int) -> None:
|
||||||
|
url = self.base_url + '/rest/setRating?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
payload['id'] = id
|
||||||
|
payload['rating'] = rating
|
||||||
|
|
||||||
|
requests.get(url + self._encodePayload(payload))
|
||||||
|
|
||||||
"""
|
"""
|
||||||
https://opensubsonic.netlify.app/docs/endpoints/scrobble/
|
https://opensubsonic.netlify.app/docs/endpoints/scrobble/
|
||||||
|
|
||||||
@@ -451,3 +470,80 @@ class SubpyConn:
|
|||||||
|
|
||||||
return random_songs
|
return random_songs
|
||||||
|
|
||||||
|
"""
|
||||||
|
https://www.subsonic.org/pages/api.jsp#getLyrics
|
||||||
|
"""
|
||||||
|
def getLyrics(self, artist: str, title: str):
|
||||||
|
url = self.base_url + '/rest/getLyrics?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
|
||||||
|
payload['artist'] = artist
|
||||||
|
payload['title'] = title
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload)).json()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
"""
|
||||||
|
https://www.subsonic.org/pages/api.jsp#createPlaylist
|
||||||
|
songs (Optional): can either be a list of songId's as a string OR a list of SubPy Song Objects
|
||||||
|
"""
|
||||||
|
def createPlaylist(self, name: str, songs: list[str] | list[Song] | None = None) -> Playlist:
|
||||||
|
url = self.base_url + '/rest/createPlaylist?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
|
||||||
|
payload['name'] = name
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload)).json()
|
||||||
|
|
||||||
|
return Playlist(response['subsonic-response']['playlist'])
|
||||||
|
|
||||||
|
"""
|
||||||
|
https://www.subsonic.org/pages/api.jsp#updatePlaylist
|
||||||
|
|
||||||
|
returns True on success
|
||||||
|
"""
|
||||||
|
def updatePlaylist(self, playlistId: str, name: str | None = None, comment: str | None = None, public: bool | None = None, songIdToAdd: list[str] | None = None) -> bool:
|
||||||
|
url = self.base_url + '/rest/updatePlaylist?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
|
||||||
|
payload['playlistId'] = playlistId
|
||||||
|
|
||||||
|
if songIdToAdd:
|
||||||
|
payload['songIdToAdd'] = songIdToAdd
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload)).json()
|
||||||
|
|
||||||
|
return bool(response['subsonic-response'])
|
||||||
|
|
||||||
|
"""
|
||||||
|
https://www.subsonic.org/pages/api.jsp#createShare
|
||||||
|
|
||||||
|
Params:
|
||||||
|
m_id: ID of a song, album or video to share. Use one id parameter for each entry to share.
|
||||||
|
description (OPTIONAL): A user-defined description that will be displayed to people visiting the shared media.
|
||||||
|
expires (OPTIONAL): The time at which the share expires. Given as milliseconds since 1970. Default is a year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of share links?
|
||||||
|
|
||||||
|
TODO @0x01FE : Finish this method
|
||||||
|
"""
|
||||||
|
def createShare(self, m_id: str, description: str | None = None, expires: int | None = None) -> list[str]:
|
||||||
|
url = self.base_url + '/rest/createShare?'
|
||||||
|
|
||||||
|
payload = self._getBasePayload()
|
||||||
|
|
||||||
|
payload['id'] = m_id
|
||||||
|
|
||||||
|
if expires:
|
||||||
|
payload['expires'] = expires
|
||||||
|
|
||||||
|
response = requests.get(url + self._encodePayload(payload)).json()
|
||||||
|
|
||||||
|
return response['subsonic-response']['shares']['share'][0]['url']
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
|
|||||||
import pygame
|
import pygame
|
||||||
import pynput
|
import pynput
|
||||||
|
|
||||||
|
from widgets import SongTableColumnConfig, SongTable
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from textual import on, events, work
|
from textual import on, events, work
|
||||||
@@ -47,13 +49,8 @@ from PIL import Image, ImageFile
|
|||||||
|
|
||||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||||
|
|
||||||
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
|
|
||||||
|
|
||||||
MUSIC_SERVER_URL = 'https://music.0x01fe.net'
|
MUSIC_SERVER_URL = 'https://music.0x01fe.net'
|
||||||
|
|
||||||
conn = SubSonic.SubpyConn(user, passw, MUSIC_SERVER_URL)
|
|
||||||
|
|
||||||
|
|
||||||
pygame.mixer.init()
|
pygame.mixer.init()
|
||||||
|
|
||||||
SONG_LIKED_CHAR = '♥'
|
SONG_LIKED_CHAR = '♥'
|
||||||
@@ -64,62 +61,23 @@ THREE_BARS_CHAR = '≡'
|
|||||||
|
|
||||||
class AlbumSongs(Screen):
|
class AlbumSongs(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
('escape', 'die()', "Back"),
|
('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):
|
def __init__(self, album_id: str):
|
||||||
self.album_id = album_id
|
self.album_id = album_id
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield DataTable(id="albumSongs")
|
songs = conn.getAlbumSongs(self.album_id)
|
||||||
|
yield SongTable(SongTableColumnConfig(track_number=True, album=False), songs, id="albumSongs")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.songs = conn.getAlbumSongs(self.album_id)
|
|
||||||
songsTable: DataTable = self.query_one(DataTable)
|
|
||||||
|
|
||||||
songsTable.cursor_type = 'row'
|
|
||||||
songsTable.add_columns("#", "Title", "Artist", "Duration", "Bitrate")
|
|
||||||
|
|
||||||
for song in self.songs:
|
|
||||||
songsTable.add_row(song.trackNumber, song.title, song.artist, song.duration, song.bitRate, key=song.id)
|
|
||||||
|
|
||||||
@on(DataTable.RowHighlighted)
|
|
||||||
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
||||||
self.selected_song_id = event.row_key.value
|
|
||||||
|
|
||||||
def action_play(self) -> None:
|
|
||||||
song_index = self._find_selected_song()
|
|
||||||
app.play_song(self.songs[song_index])
|
|
||||||
app.song_queue = self.songs[song_index + 1:]
|
|
||||||
|
|
||||||
def action_queue(self) -> None:
|
|
||||||
app.queue_song(self._find_selected_song())
|
|
||||||
|
|
||||||
# Returns index of selected song
|
|
||||||
def _find_selected_song(self) -> int:
|
|
||||||
for i, song in enumerate(self.songs):
|
|
||||||
if song.id == self.selected_song_id:
|
|
||||||
return i
|
|
||||||
|
|
||||||
# Pops and uninstalls self
|
# Pops and uninstalls self
|
||||||
def action_die(self) -> None:
|
def action_die(self) -> None:
|
||||||
app.pop_screen()
|
app.pop_screen()
|
||||||
app.uninstall_screen("albumSongs")
|
app.uninstall_screen("albumSongs")
|
||||||
|
|
||||||
def action_shuffle_mix(self) -> None:
|
|
||||||
q = self.songs.copy()
|
|
||||||
random.shuffle(q)
|
|
||||||
app.play_song(q.pop(0))
|
|
||||||
app.song_queue = q
|
|
||||||
|
|
||||||
class ArtistAlbums(Screen):
|
class ArtistAlbums(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
('escape', 'die()', "Back")
|
('escape', 'die()', "Back")
|
||||||
@@ -315,7 +273,7 @@ class Artists(Screen):
|
|||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
app.sub_title = 'Artists'
|
self.app.sub_title = 'Artists'
|
||||||
self.artists = conn.getArtists()
|
self.artists = conn.getArtists()
|
||||||
artistList = self.query_one(OptionList)
|
artistList = self.query_one(OptionList)
|
||||||
|
|
||||||
@@ -377,7 +335,8 @@ class ArtistArt(Static):
|
|||||||
|
|
||||||
if CACHE and os.path.exists(cache_path):
|
if CACHE and os.path.exists(cache_path):
|
||||||
try:
|
try:
|
||||||
self.art = Image.open(cache_path)
|
iamge = Image.open(cache_path)
|
||||||
|
self.art = image
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to load cached artist art {cache_path}: {e}")
|
logging.error(f"Failed to load cached artist art {cache_path}: {e}")
|
||||||
|
|
||||||
@@ -674,7 +633,7 @@ class Albums(Screen):
|
|||||||
dt.clear()
|
dt.clear()
|
||||||
|
|
||||||
for album in albums[:space]:
|
for album in albums[:space]:
|
||||||
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration, key=album.id)
|
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration_f, key=album.id)
|
||||||
|
|
||||||
self.last_mode = self.mode
|
self.last_mode = self.mode
|
||||||
|
|
||||||
@@ -703,11 +662,35 @@ class Albums(Screen):
|
|||||||
|
|
||||||
self._update_datatable(force_update=True)
|
self._update_datatable(force_update=True)
|
||||||
|
|
||||||
|
class PlaylistNameForm(ModalScreen[str]):
|
||||||
|
"""Screen for the creation of a playlist, merely gets the name"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Back')
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id='playlistFormModal'):
|
||||||
|
yield Label("Please enter your playlist name:")
|
||||||
|
yield Input()
|
||||||
|
with Container(id='PSbutton'):
|
||||||
|
yield Button("Enter")
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def submit(self, event: Button.Pressed) -> None:
|
||||||
|
playlist_name = self.query_one(Input).value
|
||||||
|
self.dismiss(playlist_name)
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
app.pop_screen()
|
||||||
|
|
||||||
class Playlists(Screen):
|
class Playlists(Screen):
|
||||||
|
|
||||||
playlists: list[SubSonic.Playlist] | None = None
|
playlists: list[SubSonic.Playlist] | None = None
|
||||||
selected_playlist_id: str = None
|
selected_playlist_id: str = None
|
||||||
|
BINDINGS = [
|
||||||
|
('C', 'create_playlist()', 'Create Playlist')
|
||||||
|
]
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(icon="")
|
yield Header(icon="")
|
||||||
@@ -729,7 +712,13 @@ class Playlists(Screen):
|
|||||||
playlistTable.add_columns("Name", "Songs", "Time", "Owner")
|
playlistTable.add_columns("Name", "Songs", "Time", "Owner")
|
||||||
|
|
||||||
for playlist in self.playlists:
|
for playlist in self.playlists:
|
||||||
playlistTable.add_row(playlist.name, playlist.songCount, playlist.duration, playlist.owner, key=playlist.id)
|
playlistTable.add_row(
|
||||||
|
playlist.name,
|
||||||
|
playlist.songCount,
|
||||||
|
playlist.duration,
|
||||||
|
playlist.owner,
|
||||||
|
key=playlist.id
|
||||||
|
)
|
||||||
|
|
||||||
@on(events.ScreenResume)
|
@on(events.ScreenResume)
|
||||||
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
||||||
@@ -744,6 +733,23 @@ class Playlists(Screen):
|
|||||||
app.install_screen(PlaylistSongs(self._find_selected_playlist()), "playlistSongs")
|
app.install_screen(PlaylistSongs(self._find_selected_playlist()), "playlistSongs")
|
||||||
app.push_screen('playlistSongs')
|
app.push_screen('playlistSongs')
|
||||||
|
|
||||||
|
# Needs to popup a modal text box form?
|
||||||
|
def action_create_playlist(self) -> None:
|
||||||
|
|
||||||
|
def create_playlist_api(playlist_name: str) -> None:
|
||||||
|
new_playlist = conn.createPlaylist(playlist_name)
|
||||||
|
playlistTable = self.query_one(DataTable)
|
||||||
|
playlistTable.add_row(
|
||||||
|
new_playlist.name,
|
||||||
|
new_playlist.songCount,
|
||||||
|
new_playlist.duration,
|
||||||
|
new_playlist.owner,
|
||||||
|
key=new_playlist.id
|
||||||
|
)
|
||||||
|
self.playlists.append(new_playlist)
|
||||||
|
|
||||||
|
app.push_screen(PlaylistNameForm(), create_playlist_api)
|
||||||
|
|
||||||
def _find_selected_playlist(self) -> SubSonic.Playlist:
|
def _find_selected_playlist(self) -> SubSonic.Playlist:
|
||||||
for playlist in self.playlists:
|
for playlist in self.playlists:
|
||||||
if playlist.id == self.selected_playlist_id:
|
if playlist.id == self.selected_playlist_id:
|
||||||
@@ -751,68 +757,29 @@ class Playlists(Screen):
|
|||||||
|
|
||||||
class PlaylistSongs(Screen):
|
class PlaylistSongs(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
('escape', 'die()', "Back"),
|
('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):
|
def __init__(self, playlist: SubSonic.Playlist):
|
||||||
self.playlist = playlist
|
self.playlist = playlist
|
||||||
self.songs = conn.getPlaylist(playlist.id)
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
|
w = self.app.screen.size.width
|
||||||
self.sub_title = self.playlist.name
|
self.sub_title = self.playlist.name
|
||||||
|
songs = conn.getPlaylist(self.playlist.id)
|
||||||
yield Header(icon="")
|
yield Header(icon="")
|
||||||
yield DataTable()
|
yield SongTable(songs=songs, max_col_width=(w // 3) - 5)
|
||||||
yield Footer()
|
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
|
# Pops and uninstalls self
|
||||||
def action_die(self) -> None:
|
def action_die(self) -> None:
|
||||||
app.pop_screen()
|
app.pop_screen()
|
||||||
app.uninstall_screen("playlistSongs")
|
app.uninstall_screen("playlistSongs")
|
||||||
|
|
||||||
@on(DataTable.RowHighlighted)
|
|
||||||
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
||||||
self.selected_song_id = self.songs[int(event.row_key.value)].id
|
|
||||||
|
|
||||||
def action_play(self) -> None:
|
|
||||||
song_index = self._find_selected_song()
|
|
||||||
app.play_song(self.songs[song_index])
|
|
||||||
app.song_queue = self.songs[song_index + 1:]
|
|
||||||
|
|
||||||
def action_queue(self) -> None:
|
|
||||||
song_id: int = self._find_selected_song()
|
|
||||||
app.queue_song(self.songs[song_id])
|
|
||||||
|
|
||||||
# Returns index of selected song
|
|
||||||
def _find_selected_song(self) -> int:
|
|
||||||
for i, song in enumerate(self.songs):
|
|
||||||
if song.id == self.selected_song_id:
|
|
||||||
return i
|
|
||||||
|
|
||||||
def action_shuffle_mix(self) -> None:
|
|
||||||
q = self.songs.copy()
|
|
||||||
random.shuffle(q)
|
|
||||||
app.play_song(q.pop(0))
|
|
||||||
app.song_queue = q
|
|
||||||
|
|
||||||
class SongsScreen(Screen):
|
class SongsScreen(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("p", "play_song()", "Play Song"),
|
("p", "play()", "Play Song"),
|
||||||
("q", "queue_song()", "Queue Song"),
|
("q", "queue_song()", "Queue Song"),
|
||||||
("m", "shuffle_mix()", 'Shuffle Play'),
|
("m", "shuffle_mix()", 'Shuffle Play'),
|
||||||
('a', "page_backward()", "Prev. Page"),
|
('a', "page_backward()", "Prev. Page"),
|
||||||
@@ -865,6 +832,21 @@ class SongsScreen(Screen):
|
|||||||
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
||||||
app.sub_title = "Songs"
|
app.sub_title = "Songs"
|
||||||
|
|
||||||
|
@on(DataTable.RowHighlighted)
|
||||||
|
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
self.selected_song_id = event.row_key.value
|
||||||
|
|
||||||
|
def _find_selected_song(self) -> int | None:
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
if song.id == self.selected_song_id:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
def action_play(self) -> None:
|
||||||
|
song_index = self._find_selected_song()
|
||||||
|
app.play_song(self.songs[song_index])
|
||||||
|
app.song_queue = self.songs[song_index + 1:]
|
||||||
|
|
||||||
def _get_offset(self) -> int:
|
def _get_offset(self) -> int:
|
||||||
return (self.page[self.mode]) * 30
|
return (self.page[self.mode]) * 30
|
||||||
|
|
||||||
@@ -907,7 +889,7 @@ class SongsScreen(Screen):
|
|||||||
dt.clear()
|
dt.clear()
|
||||||
|
|
||||||
for song in self.songs[offset:offset + space]:
|
for song in self.songs[offset:offset + space]:
|
||||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
|
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
|
||||||
|
|
||||||
def update_all(self, dt: DataTable) -> None:
|
def update_all(self, dt: DataTable) -> None:
|
||||||
space = 30
|
space = 30
|
||||||
@@ -920,7 +902,7 @@ class SongsScreen(Screen):
|
|||||||
dt.add_columns("Title", "Album", "Artist", "Plays", "Duration")
|
dt.add_columns("Title", "Album", "Artist", "Plays", "Duration")
|
||||||
|
|
||||||
for song in self.songs:
|
for song in self.songs:
|
||||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
|
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
|
||||||
|
|
||||||
class CoverArtPixels(Container):
|
class CoverArtPixels(Container):
|
||||||
|
|
||||||
@@ -948,7 +930,8 @@ class CoverArtPixels(Container):
|
|||||||
|
|
||||||
if CACHE and os.path.exists(cache_path):
|
if CACHE and os.path.exists(cache_path):
|
||||||
try:
|
try:
|
||||||
self.cover = Image.open(cache_path)
|
image = Image.open(cache_path)
|
||||||
|
self.cover = image
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to load cached cover art {cache_path}: {e}")
|
logging.error(f"Failed to load cached cover art {cache_path}: {e}")
|
||||||
|
|
||||||
@@ -1263,12 +1246,14 @@ class SubPy(App):
|
|||||||
"playlists" : Playlists
|
"playlists" : Playlists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conn: SubSonic.SubpyConn
|
||||||
currently_playing: SubSonic.Song | None = reactive(SubSonic.Song())
|
currently_playing: SubSonic.Song | None = reactive(SubSonic.Song())
|
||||||
is_playing: bool = False
|
is_playing: bool = False
|
||||||
song_queue: list[SubSonic.Song] = reactive([])
|
song_queue: list[SubSonic.Song] = reactive([])
|
||||||
user_queue: list[SubSonic.Song] = reactive([])
|
user_queue: list[SubSonic.Song] = reactive([])
|
||||||
play_history: list[SubSonic.Song] = []
|
play_history: list[SubSonic.Song] = []
|
||||||
is_paused: bool = False
|
is_paused: bool = False
|
||||||
|
is_loading: bool = False
|
||||||
place: int = 0
|
place: int = 0
|
||||||
|
|
||||||
navidrome: Navidrome.NavidromeAPI
|
navidrome: Navidrome.NavidromeAPI
|
||||||
@@ -1278,6 +1263,8 @@ class SubPy(App):
|
|||||||
check_playing_timer: Timer | None = None
|
check_playing_timer: Timer | None = None
|
||||||
check_music_commands: Timer
|
check_music_commands: Timer
|
||||||
|
|
||||||
|
_prefetch_started_for: str | None = None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Modes:
|
Modes:
|
||||||
0: standard queue, is done when queue is done
|
0: standard queue, is done when queue is done
|
||||||
@@ -1286,6 +1273,10 @@ class SubPy(App):
|
|||||||
"""
|
"""
|
||||||
queue_mode: int = 0
|
queue_mode: int = 0
|
||||||
|
|
||||||
|
def __init__(self, conn: SubSonic.SubpyConn):
|
||||||
|
self.conn = conn
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.title = "SubPy"
|
self.title = "SubPy"
|
||||||
self.switch_mode('main')
|
self.switch_mode('main')
|
||||||
@@ -1294,9 +1285,35 @@ class SubPy(App):
|
|||||||
|
|
||||||
self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL)
|
self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL)
|
||||||
|
|
||||||
|
@work(exclusive=False, thread=True)
|
||||||
|
def _prefetch_next_song(self, song: SubSonic.Song) -> None:
|
||||||
|
audio_cache_path = f'.cache/audio/{song.id}.ogg'
|
||||||
|
|
||||||
|
if os.path.exists(audio_cache_path):
|
||||||
|
logging.debug(f'Prefetch: "{song.title}" already cached.')
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.debug(f'Prefetch: downloading "{song.title}" by {song.artist}...')
|
||||||
|
try:
|
||||||
|
if BITRATE:
|
||||||
|
r = self.conn.stream(song.id, maxBitRate=BITRATE)
|
||||||
|
else:
|
||||||
|
r = self.conn.stream(song.id)
|
||||||
|
|
||||||
|
audio_content: bytes = r.content
|
||||||
|
|
||||||
|
if CACHE:
|
||||||
|
self._cache_audio(audio_cache_path, audio_content)
|
||||||
|
|
||||||
|
logging.debug(f'Prefetch: "{song.title}" cached successfully.')
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f'Prefetch failed for "{song.title}": {e}')
|
||||||
|
|
||||||
@work(exclusive=True)
|
@work(exclusive=True)
|
||||||
async def play_song(self, song: SubSonic.Song) -> None:
|
async def play_song(self, song: SubSonic.Song) -> None:
|
||||||
|
self.is_loading = True
|
||||||
self.currently_playing = song
|
self.currently_playing = song
|
||||||
|
self._prefetch_started_for = None
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.is_playing = True
|
self.is_playing = True
|
||||||
logging.debug(f"Playing song {song.title} by {song.artist}.")
|
logging.debug(f"Playing song {song.title} by {song.artist}.")
|
||||||
@@ -1329,6 +1346,8 @@ class SubPy(App):
|
|||||||
pygame.mixer.music.load(audio_data)
|
pygame.mixer.music.load(audio_data)
|
||||||
pygame.mixer.music.play()
|
pygame.mixer.music.play()
|
||||||
|
|
||||||
|
self.is_loading = False
|
||||||
|
|
||||||
self.check_playing_timer = self.set_interval(1 / 5, self.check_done_playing, pause=False)
|
self.check_playing_timer = self.set_interval(1 / 5, self.check_done_playing, pause=False)
|
||||||
self.progess_timer = self.set_interval(0.3, self.update_progress, pause=False)
|
self.progess_timer = self.set_interval(0.3, self.update_progress, pause=False)
|
||||||
|
|
||||||
@@ -1358,12 +1377,16 @@ class SubPy(App):
|
|||||||
file.write(content)
|
file.write(content)
|
||||||
|
|
||||||
def check_done_playing(self) -> None:
|
def check_done_playing(self) -> None:
|
||||||
|
if self.is_loading:
|
||||||
|
return
|
||||||
|
|
||||||
if not pygame.mixer.music.get_busy() and not self.is_paused and self.currently_playing:
|
if not pygame.mixer.music.get_busy() and not self.is_paused and self.currently_playing:
|
||||||
|
if self.check_playing_timer:
|
||||||
|
self.check_playing_timer.stop()
|
||||||
|
|
||||||
if self.queue_mode == 2:
|
if self.queue_mode == 2:
|
||||||
self.play_song(app.currently_playing)
|
self.play_song(app.currently_playing)
|
||||||
|
|
||||||
if self.check_playing_timer:
|
|
||||||
self.check_playing_timer.stop()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.play_history.append(self.currently_playing)
|
self.play_history.append(self.currently_playing)
|
||||||
@@ -1386,10 +1409,9 @@ class SubPy(App):
|
|||||||
if next_song:
|
if next_song:
|
||||||
self.play_song(next_song)
|
self.play_song(next_song)
|
||||||
|
|
||||||
if self.check_playing_timer:
|
|
||||||
self.check_playing_timer.stop()
|
|
||||||
|
|
||||||
def action_skip(self) -> None:
|
def action_skip(self) -> None:
|
||||||
|
if self.is_loading:
|
||||||
|
return
|
||||||
|
|
||||||
if self.check_playing_timer:
|
if self.check_playing_timer:
|
||||||
self.check_playing_timer.stop()
|
self.check_playing_timer.stop()
|
||||||
@@ -1427,22 +1449,36 @@ class SubPy(App):
|
|||||||
self.play_song(song_to_play)
|
self.play_song(song_to_play)
|
||||||
|
|
||||||
def update_progress(self) -> None:
|
def update_progress(self) -> None:
|
||||||
if isinstance(self.screen, Player) and self.currently_playing:
|
if self.currently_playing:
|
||||||
current_place_label = self.screen.query_one(CurrentPlace)
|
|
||||||
place = pygame.mixer.music.get_pos()
|
place = pygame.mixer.music.get_pos()
|
||||||
current_place_label.place = place
|
|
||||||
|
|
||||||
duration_label = self.screen.query_one(TotalDuration)
|
|
||||||
duration_label.duration = self.currently_playing.duration
|
|
||||||
|
|
||||||
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
|
|
||||||
|
|
||||||
progress: int = round(place/1000)
|
progress: int = round(place/1000)
|
||||||
|
|
||||||
progressbar.update(
|
if self.currently_playing.duration > 0 and progress >= self.currently_playing.duration * 0.8:
|
||||||
total = self.currently_playing.duration,
|
next_song: SubSonic.Song | None = None
|
||||||
progress=progress
|
if self.user_queue:
|
||||||
)
|
next_song = self.user_queue[0]
|
||||||
|
elif self.song_queue:
|
||||||
|
next_song = self.song_queue[0]
|
||||||
|
elif self.queue_mode == 2:
|
||||||
|
next_song = self.currently_playing
|
||||||
|
|
||||||
|
if next_song and self._prefetch_started_for != next_song.id:
|
||||||
|
self._prefetch_started_for = next_song.id
|
||||||
|
self._prefetch_next_song(next_song)
|
||||||
|
|
||||||
|
if isinstance(self.screen, Player):
|
||||||
|
current_place_label = self.screen.query_one(CurrentPlace)
|
||||||
|
current_place_label.place = place
|
||||||
|
|
||||||
|
duration_label = self.screen.query_one(TotalDuration)
|
||||||
|
duration_label.duration = self.currently_playing.duration
|
||||||
|
|
||||||
|
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
|
||||||
|
|
||||||
|
progressbar.update(
|
||||||
|
total = self.currently_playing.duration,
|
||||||
|
progress=progress
|
||||||
|
)
|
||||||
|
|
||||||
def queue_song(self, song: SubSonic.Song) -> None:
|
def queue_song(self, song: SubSonic.Song) -> None:
|
||||||
logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.')
|
logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.')
|
||||||
@@ -1502,11 +1538,19 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
|
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
|
||||||
|
|
||||||
q = multiprocessing.Queue(-1)
|
q = multiprocessing.Queue(0)
|
||||||
p = multiprocessing.Process(target=listen_media_keys, args=(q,))
|
p = multiprocessing.Process(target=listen_media_keys, args=(q,))
|
||||||
p.start()
|
p.start()
|
||||||
|
|
||||||
app = SubPy()
|
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
|
||||||
|
conn = SubSonic.SubpyConn(
|
||||||
|
user,
|
||||||
|
passw,
|
||||||
|
MUSIC_SERVER_URL,
|
||||||
|
client_name="SubPy"
|
||||||
|
)
|
||||||
|
|
||||||
|
app = SubPy(conn)
|
||||||
app.music_command_queue = q
|
app.music_command_queue = q
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@@ -169,6 +169,48 @@ QueueScreen {
|
|||||||
border: grey;
|
border: grey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal Container {
|
||||||
|
height: 75%;
|
||||||
|
width: 75%;
|
||||||
|
border: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistSelectionModal Label {
|
||||||
|
text-style: bold;
|
||||||
|
color: #0073e6;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#PSbutton {
|
||||||
|
width: 100%;
|
||||||
|
align: right middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm Button {
|
||||||
|
background: #0080ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaylistNameForm Label {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playlistFormModal {
|
||||||
|
height: 30%;
|
||||||
|
width: 70%;
|
||||||
|
border: grey;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
#songModeSelectorList {
|
#songModeSelectorList {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0.2fr;
|
width: 0.2fr;
|
||||||
@@ -203,3 +245,56 @@ QueueScreen {
|
|||||||
margin: 1 1;
|
margin: 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SongTable Input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal Horizontal {
|
||||||
|
min-width: 30%;
|
||||||
|
min-height: 3;
|
||||||
|
max-height: 5;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border: blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkModal Label {
|
||||||
|
content-align: center middle;
|
||||||
|
padding: 1 2 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Vertical {
|
||||||
|
max-width: 50%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 2;
|
||||||
|
border: blue;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Label {
|
||||||
|
content-align: center middle;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Horizontal {
|
||||||
|
align: center middle;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal RadioSet {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingModal Button {
|
||||||
|
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ name = "subpy"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = "<3.14,>=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pillow>=12.0.0",
|
"pillow>=12.0.0",
|
||||||
"pygame>=2.6.1",
|
"pygame>=2.6.1",
|
||||||
|
|||||||
+22
@@ -8,3 +8,25 @@
|
|||||||
- Config for no fancy images UI
|
- Config for no fancy images UI
|
||||||
- Auto cache deletion (if not played in x number of days remove)
|
- Auto cache deletion (if not played in x number of days remove)
|
||||||
- Accurate play time tracking server (optionally) linked to player
|
- Accurate play time tracking server (optionally) linked to player
|
||||||
|
- Create / Edit playlist features
|
||||||
|
|
||||||
|
# Features
|
||||||
|
- Playlist Management
|
||||||
|
- [x] Create Playlist
|
||||||
|
- [x] Add songs
|
||||||
|
- This is only really half done, more screens need this feature, but I'm moving this to a tech debt state
|
||||||
|
- [ ] Remove songs
|
||||||
|
- [ ] Change order
|
||||||
|
- [ ] Rename
|
||||||
|
- [ ] Set public / private
|
||||||
|
|
||||||
|
# Technical Features
|
||||||
|
- [ ] Custom song table widget that's an extension of DataTable. There are some universal functions / keybinds a user should be able to use when looking at a list of songs, ex:
|
||||||
|
- [x] Add to playlist
|
||||||
|
- [x] Favorite
|
||||||
|
- [x] Queue
|
||||||
|
- [x] Play
|
||||||
|
- [x] Share
|
||||||
|
- [x] Rate
|
||||||
|
- [ ] Go to album
|
||||||
|
- [ ] Go to artist
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = "==3.13.*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
@@ -33,22 +33,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -148,31 +132,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -232,8 +191,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/dc/6d63019133e39e2b2
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/17/6c247bf9d8de2813f6015671f242333534797e81bdac9e85516fb57dfb00/pyobjc_core-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c44b76d8306a130c9eb0cb79d86fd6675c8ba3e5b458e78095d271a10cd38b6a", size = 679700, upload-time = "2025-10-21T07:51:09.518Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/17/6c247bf9d8de2813f6015671f242333534797e81bdac9e85516fb57dfb00/pyobjc_core-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c44b76d8306a130c9eb0cb79d86fd6675c8ba3e5b458e78095d271a10cd38b6a", size = 679700, upload-time = "2025-10-21T07:51:09.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/a3/1b26c438c78821e5a82b9c02f7b19a86097aeb2c51132d06e159acc22dc2/pyobjc_core-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c617551e0ab860c49229fcec0135a5cde702485f22254ddc17205eb24b7fc55", size = 721370, upload-time = "2025-10-21T07:51:55.981Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/a3/1b26c438c78821e5a82b9c02f7b19a86097aeb2c51132d06e159acc22dc2/pyobjc_core-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c617551e0ab860c49229fcec0135a5cde702485f22254ddc17205eb24b7fc55", size = 721370, upload-time = "2025-10-21T07:51:55.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/b1/6df7d4b0d9f0088855a59f6af59230d1191f78fa84ca68851723272f1916/pyobjc_core-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c2709ff43ac5c2e9e2c574ae515d3aa0e470345847a4d96c5d4a04b1b86e966d", size = 672302, upload-time = "2025-10-21T07:52:39.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/10/3a029797c0a22c730ee0d0149ac34ab27afdf51667f96aa23a8ebe7dc3c9/pyobjc_core-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:eb6b987e53291e7cafd8f71a80a2dd44d7afec4202a143a3e47b75cb9cdb5716", size = 713255, upload-time = "2025-10-21T07:53:25.478Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -250,8 +207,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/8c/79/0b7a00bcc7561c816
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/cf/ae603c46217c04ec7598c62a2d46fa9b6ab66e127148bff1f352b850fc72/pyobjc_framework_applicationservices-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9ff39c0301f2430253fbfea114afb00594426e0b66a1bec1c28cd60f75d02005", size = 32871, upload-time = "2025-10-21T07:54:15.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/cf/ae603c46217c04ec7598c62a2d46fa9b6ab66e127148bff1f352b850fc72/pyobjc_framework_applicationservices-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9ff39c0301f2430253fbfea114afb00594426e0b66a1bec1c28cd60f75d02005", size = 32871, upload-time = "2025-10-21T07:54:15.33Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/79/a578c8b1aa8634c2c9f8bbd66a3cdc385013a4cd9558741a4da26c040e51/pyobjc_framework_applicationservices-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ecd7651ab330790722f3590465392dbab3d76be0370ff7e015584053d571e218", size = 33132, upload-time = "2025-10-21T07:54:18.388Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/79/a578c8b1aa8634c2c9f8bbd66a3cdc385013a4cd9558741a4da26c040e51/pyobjc_framework_applicationservices-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ecd7651ab330790722f3590465392dbab3d76be0370ff7e015584053d571e218", size = 33132, upload-time = "2025-10-21T07:54:18.388Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/8b/336788981a3c1aa00e75d021a5ed00e453587da1eee0d55bb8b674f2b623/pyobjc_framework_applicationservices-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b153a0b8e915751ca50651be6f9fe002ef7536677f5c37a4dff0f3fd98e5b16a", size = 33007, upload-time = "2025-10-21T07:54:21.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/50/0e300544e8204d02b4a0477fa157e904921c98b15f67e19b4a49a80f02c9/pyobjc_framework_applicationservices-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:56f8fabdc3972fc9a97630b24c31d8b852502c3273071ab3d3b467cc5e7c6431", size = 33250, upload-time = "2025-10-21T07:54:25.395Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -265,8 +220,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de64
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/29/cfef5f021576976698c6ae195fa304238b9f6716e1b3eb11258d2572afe9/pyobjc_framework_cocoa-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:13e573f5093f4158f305b1bac5e1f783881ce2f5f4a69f3c80cb000f76731259", size = 384659, upload-time = "2025-10-21T07:59:34.859Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/29/cfef5f021576976698c6ae195fa304238b9f6716e1b3eb11258d2572afe9/pyobjc_framework_cocoa-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:13e573f5093f4158f305b1bac5e1f783881ce2f5f4a69f3c80cb000f76731259", size = 384659, upload-time = "2025-10-21T07:59:34.859Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/37/d2d9a143ab5387815a00f478916a52425c4792678366ef6cedf20b8cc9cd/pyobjc_framework_cocoa-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3b167793cd1b509eaf693140ace9be1f827a2c8686fceb8c599907661f608bc2", size = 388787, upload-time = "2025-10-21T08:00:00.006Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/37/d2d9a143ab5387815a00f478916a52425c4792678366ef6cedf20b8cc9cd/pyobjc_framework_cocoa-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3b167793cd1b509eaf693140ace9be1f827a2c8686fceb8c599907661f608bc2", size = 388787, upload-time = "2025-10-21T08:00:00.006Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/15/0a6122e430d0e2ba27ad0e345b89f85346805f39d6f97eea6430a74350d9/pyobjc_framework_cocoa-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a2b6fb9ab3e5ab6db04dfa17828a97894e7da85dd8600885c72a0c2c2214d618", size = 384890, upload-time = "2025-10-21T08:00:25.286Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/d7/1a3ad814d427c08b99405e571e47a0219598930ad73850ac02d164d88cd0/pyobjc_framework_cocoa-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:32ff10250a57f72a0b6eca85b790dcc87548ff71d33d0436ffb69680d5e2f308", size = 388925, upload-time = "2025-10-21T08:00:47.309Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -282,8 +235,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/36/32ec183e555b73152
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/64/31da2b1236c710b963510fc03008ebe607d03e2c0288467db9bf9f297873/pyobjc_framework_coretext-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecd424cf6da1a69cad40ef4007bc5af842ccb7456c5fcc4c9aded40e3e0c22ba", size = 30119, upload-time = "2025-10-21T08:04:57.402Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/64/31da2b1236c710b963510fc03008ebe607d03e2c0288467db9bf9f297873/pyobjc_framework_coretext-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecd424cf6da1a69cad40ef4007bc5af842ccb7456c5fcc4c9aded40e3e0c22ba", size = 30119, upload-time = "2025-10-21T08:04:57.402Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/1d/d23fa47ffb6ad32e26a58e357619b5564b4f6e421a839d12961cce521c8f/pyobjc_framework_coretext-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:60e84e46e0aeb12101a4354c39ce84066107773b0c96fdc4ff15fd1662dc88d8", size = 30702, upload-time = "2025-10-21T08:05:00.387Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/1d/d23fa47ffb6ad32e26a58e357619b5564b4f6e421a839d12961cce521c8f/pyobjc_framework_coretext-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:60e84e46e0aeb12101a4354c39ce84066107773b0c96fdc4ff15fd1662dc88d8", size = 30702, upload-time = "2025-10-21T08:05:00.387Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/e4/96caefd91817d0f82aaae089e4421cbbef2a216933b5c98435ee2927fbef/pyobjc_framework_coretext-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6ecf89af6de87072f1615fb89d7ed51b345000850a9b827774f262bf6be5acac", size = 30104, upload-time = "2025-10-21T08:05:03.331Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/b5/9152c1a2d8a6fb06d48a36d95b5bb919e820a2f623ca8313ab5eba263be0/pyobjc_framework_coretext-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ddc34c91d16a653db81963141d29f8fc82550fc7a39ed39ff0332764d844ffe1", size = 30714, upload-time = "2025-10-21T08:05:07.092Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -298,8 +249,6 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/0b/3c34fc9de790daff5
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/d8/05f8fb5f27af69c0b5a9802f220a7c00bbe595c790e13edefa042603b957/pyobjc_framework_quartz-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ece7a05aa2bfc3aa215f1a7c8580e873f3867ba40d0006469618cc2ceb796578", size = 219201, upload-time = "2025-10-21T08:17:59.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/d8/05f8fb5f27af69c0b5a9802f220a7c00bbe595c790e13edefa042603b957/pyobjc_framework_quartz-12.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ece7a05aa2bfc3aa215f1a7c8580e873f3867ba40d0006469618cc2ceb796578", size = 219201, upload-time = "2025-10-21T08:17:59.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/1228f86de266874e20c04f04736a5f11c5a29a1839efde594ba4097d0255/pyobjc_framework_quartz-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f1b2e34f6f0dd023f80a0e875af4dab0ad27fccac239da9ad3d311a2d2578e27", size = 224330, upload-time = "2025-10-21T08:18:14.776Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/3f/1228f86de266874e20c04f04736a5f11c5a29a1839efde594ba4097d0255/pyobjc_framework_quartz-12.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f1b2e34f6f0dd023f80a0e875af4dab0ad27fccac239da9ad3d311a2d2578e27", size = 224330, upload-time = "2025-10-21T08:18:14.776Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/23/ec1804bd10c409fe98ba086329569914fd10b6814208ca6168e81ca0ec1a/pyobjc_framework_quartz-12.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a2cde43ddc5d2a9ace13af38b4a9ee70dbd47d1707ec6b7185a1a3a1d48e54f9", size = 219581, upload-time = "2025-10-21T08:18:30.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/c2/cf89fda2e477c0c4e2a8aae86202c2891a83bead24e8a7fc733ff490dffc/pyobjc_framework_quartz-12.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9b928d551ec779141558d986684c19f8f5742251721f440d7087257e4e35b22b", size = 224613, upload-time = "2025-10-21T08:18:45.39Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+332
@@ -0,0 +1,332 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pyperclip
|
||||||
|
|
||||||
|
from textual import on
|
||||||
|
from textual.widgets import DataTable, Input, Label, Button, RadioSet, RadioButton
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
|
|
||||||
|
import SubSonic
|
||||||
|
|
||||||
|
def fix_duration(seconds: int) -> str:
|
||||||
|
min, sec = divmod(seconds, 60)
|
||||||
|
|
||||||
|
min = max(0, min)
|
||||||
|
sec = max(0, sec)
|
||||||
|
|
||||||
|
formatted = f"{min}:{sec:02d}"
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
class RatingModal(ModalScreen[int]):
|
||||||
|
"""Modal for getting a numerical rating for an Album, Artist, or Song"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Close')
|
||||||
|
]
|
||||||
|
|
||||||
|
thing_title: str
|
||||||
|
selected_rating: int = 0
|
||||||
|
|
||||||
|
def __init__(self, thing_title: str):
|
||||||
|
self.thing_title = thing_title
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
label: Label = self.query_one(Label)
|
||||||
|
label.update(f'Select a rating for {self.thing_title}')
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical():
|
||||||
|
yield Label()
|
||||||
|
with Horizontal():
|
||||||
|
with RadioSet():
|
||||||
|
yield RadioButton("0", value = True)
|
||||||
|
yield RadioButton("1")
|
||||||
|
yield RadioButton("2")
|
||||||
|
yield RadioButton("3")
|
||||||
|
yield RadioButton("4")
|
||||||
|
yield RadioButton("5")
|
||||||
|
with Horizontal():
|
||||||
|
yield Button("Submit")
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def selection_submit(self, event: Button.Pressed) -> None:
|
||||||
|
self.dismiss(self.selected_rating)
|
||||||
|
|
||||||
|
@on(RadioSet.Changed)
|
||||||
|
def selection_changed(self, event: RadioSet.Changed) -> None:
|
||||||
|
self.selected_rating = event.radio_set.pressed_index
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
class ShareLinkModal(ModalScreen):
|
||||||
|
"""Modal for copying a generated share link"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Close')
|
||||||
|
]
|
||||||
|
|
||||||
|
link: str
|
||||||
|
|
||||||
|
def __init__(self, link: str):
|
||||||
|
self.link = link
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
label: Label = self.query_one(Label)
|
||||||
|
label.update(self.link)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
yield Label()
|
||||||
|
yield Button("Copy Link")
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def copy_link(self) -> None:
|
||||||
|
pyperclip.copy(self.link)
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
class PlaylistSelectionModal(ModalScreen[str]):
|
||||||
|
"""Modal for selecting a playlist"""
|
||||||
|
|
||||||
|
playlists: list[SubSonic.Playlist] = []
|
||||||
|
selected_playlist_id: str = ""
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die', 'Back')
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container():
|
||||||
|
yield Label("Select a playlist")
|
||||||
|
yield DataTable()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.playlists = self.app.conn.getPlaylists()
|
||||||
|
playlistTable = self.query_one(DataTable)
|
||||||
|
|
||||||
|
playlistTable.cursor_type = "row"
|
||||||
|
playlistTable.add_columns("Name", "Songs", "Time", "Owner")
|
||||||
|
|
||||||
|
for playlist in self.playlists:
|
||||||
|
playlistTable.add_row(
|
||||||
|
playlist.name,
|
||||||
|
playlist.songCount,
|
||||||
|
playlist.duration,
|
||||||
|
playlist.owner,
|
||||||
|
key=playlist.id
|
||||||
|
)
|
||||||
|
|
||||||
|
@on(DataTable.RowHighlighted)
|
||||||
|
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
self.selected_playlist_id = event.row_key.value
|
||||||
|
|
||||||
|
@on(DataTable.RowSelected)
|
||||||
|
def playlist_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
self.dismiss(self.selected_playlist_id)
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
self.dismiss("")
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SongTableColumnConfig:
|
||||||
|
track_number: bool = False
|
||||||
|
title: bool = True
|
||||||
|
album: bool = True
|
||||||
|
artist: bool = True
|
||||||
|
plays: bool = True
|
||||||
|
duration: bool = True
|
||||||
|
rating: bool = False
|
||||||
|
bitrate: bool = False
|
||||||
|
|
||||||
|
# Maps config field -> (column header, song attribute)
|
||||||
|
COLUMN_MAPPING: list[tuple[str, str, str]] = [
|
||||||
|
('track_number', '#', 'trackNumber'),
|
||||||
|
('title', 'Title', 'title'),
|
||||||
|
('album', 'Album', 'album'),
|
||||||
|
('artist', 'Artist', 'artist'),
|
||||||
|
('plays', 'Plays', 'playCount'),
|
||||||
|
('duration', 'Duration', 'duration'),
|
||||||
|
('rating', 'Rating', 'rating'),
|
||||||
|
('bitrate', 'Bitrate', 'bitRate')
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
A custom DataTable designed to hold and display songs
|
||||||
|
|
||||||
|
TODO @0x01fe : needs optional paging on init
|
||||||
|
"""
|
||||||
|
class SongTable(Widget):
|
||||||
|
BINDINGS = [
|
||||||
|
('p', 'play()', 'Play Song'),
|
||||||
|
('q', 'queue()', 'Queue Song'),
|
||||||
|
('m', 'shuffle()', 'Shuffle Play'),
|
||||||
|
('A', 'add_to_playlist()', 'Add to Playlist'),
|
||||||
|
('f', 'star_song()', 'Star Song'),
|
||||||
|
('S', 'share_song()', 'Share Song'),
|
||||||
|
('R', 'rate_song()', 'Rate Song'),
|
||||||
|
Binding('/', 'filter()', 'Search', show=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
columns: SongTableColumnConfig
|
||||||
|
songs: list[SubSonic.Song] = []
|
||||||
|
filter_active: bool = False
|
||||||
|
selected_song_id: str | None = None
|
||||||
|
max_col_width: int | None = None
|
||||||
|
|
||||||
|
OVERFLOW_INDICATOR: str = "..."
|
||||||
|
|
||||||
|
def __init__(self, columns: SongTableColumnConfig | None = SongTableColumnConfig(), songs: list[SubSonic.Song] | None = [], max_col_width: int | None = None, **kwargs):
|
||||||
|
self.columns = columns
|
||||||
|
self.songs = songs
|
||||||
|
self.max_col_width = max_col_width
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Input(placeholder="Search", id="filter")
|
||||||
|
yield DataTable()
|
||||||
|
|
||||||
|
def _format_cell(self, config_attr: str, value):
|
||||||
|
"""Format cell values for display in the DataTable."""
|
||||||
|
if config_attr == "duration":
|
||||||
|
try:
|
||||||
|
return fix_duration(int(value or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fix_duration(0)
|
||||||
|
if self.max_col_width and type(value) == str and len(value) > self.max_col_width:
|
||||||
|
value = value[:self.max_col_width - len(self.OVERFLOW_INDICATOR)]
|
||||||
|
value += self.OVERFLOW_INDICATOR
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _row_data_for_song(self, song: SubSonic.Song) -> list:
|
||||||
|
"""Build DataTable row values based on configured columns."""
|
||||||
|
row_data = []
|
||||||
|
for config_attr, _, song_attr in COLUMN_MAPPING:
|
||||||
|
if getattr(self.columns, config_attr):
|
||||||
|
value = getattr(song, song_attr)
|
||||||
|
row_data.append(self._format_cell(config_attr, value))
|
||||||
|
return row_data
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.focus()
|
||||||
|
dt.cursor_type = "row"
|
||||||
|
|
||||||
|
# Add columns based on config
|
||||||
|
for config_attr, header, _ in COLUMN_MAPPING:
|
||||||
|
if getattr(self.columns, config_attr):
|
||||||
|
dt.add_column(header)
|
||||||
|
|
||||||
|
# Add rows for existing songs
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(i))
|
||||||
|
|
||||||
|
def add_song(self, song: SubSonic.Song) -> None:
|
||||||
|
self.songs.append(song)
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(len(self.songs) - 1))
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def search_submit(self, event: Input.Submitted) -> None:
|
||||||
|
search_value = event.value.lower()
|
||||||
|
|
||||||
|
dt: DataTable = self.query_one(DataTable)
|
||||||
|
dt.clear()
|
||||||
|
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
if (
|
||||||
|
re.search(search_value, song.title.lower())
|
||||||
|
or re.search(search_value, song.artist.lower())
|
||||||
|
or re.search(search_value, song.album.lower())
|
||||||
|
):
|
||||||
|
dt.add_row(*self._row_data_for_song(song), key=str(i))
|
||||||
|
|
||||||
|
@on(DataTable.RowHighlighted)
|
||||||
|
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
if not self.songs:
|
||||||
|
return
|
||||||
|
self.selected_song_id = self.songs[int(event.row_key.value)].id
|
||||||
|
|
||||||
|
def action_play(self) -> None:
|
||||||
|
song_index: int = self._find_selected_song_index()
|
||||||
|
self.app.play_song(self.songs[song_index])
|
||||||
|
self.app.song_queue = self.songs[song_index + 1:]
|
||||||
|
|
||||||
|
def action_queue(self) -> None:
|
||||||
|
song_index: int = self._find_selected_song_index()
|
||||||
|
self.app.queue_song(self.songs[song_index])
|
||||||
|
|
||||||
|
def action_shuffle(self) -> None:
|
||||||
|
q: list[SubSonic.Song] = self.songs.copy()
|
||||||
|
random.shuffle(q)
|
||||||
|
self.app.play_song(q.pop(0))
|
||||||
|
self.app.song_queue = q
|
||||||
|
|
||||||
|
def action_add_to_playlist(self) -> None:
|
||||||
|
|
||||||
|
def process_modal_selection(playlist_id: str) -> None:
|
||||||
|
self.app.conn.updatePlaylist(playlist_id, songIdToAdd=[self.selected_song_id])
|
||||||
|
self.app.notify("Added song to playlist!")
|
||||||
|
|
||||||
|
self.app.push_screen(PlaylistSelectionModal(), process_modal_selection)
|
||||||
|
|
||||||
|
def action_star_song(self) -> None:
|
||||||
|
song = self.songs[self._find_selected_song_index()]
|
||||||
|
|
||||||
|
is_song_starred: bool = self.app.navidrome.isSongStarred(song.id)
|
||||||
|
|
||||||
|
if not is_song_starred:
|
||||||
|
asyncio.create_task(asyncio.to_thread(self.app.conn.star, self.selected_song_id))
|
||||||
|
self.app.notify(f'Starred song "{song.title}"')
|
||||||
|
else:
|
||||||
|
asyncio.create_task(asyncio.to_thread(self.app.conn.unstar, self.selected_song_id))
|
||||||
|
self.app.notify(f'Unstarred song "{song.title}"')
|
||||||
|
|
||||||
|
def action_share_song(self) -> None:
|
||||||
|
share_link: str = self.app.conn.createShare(self.selected_song_id)
|
||||||
|
self.app.push_screen(ShareLinkModal(share_link))
|
||||||
|
|
||||||
|
def action_rate_song(self) -> None:
|
||||||
|
song: SubSonic.Song = self.songs[self._find_selected_song_index()]
|
||||||
|
|
||||||
|
def process_modal_selection(rating: int | None) -> None:
|
||||||
|
if rating == None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.app.conn.setRating(self.selected_song_id, rating)
|
||||||
|
if rating != 0:
|
||||||
|
self.app.notify(f'Rated "{song.title}" a {rating}.')
|
||||||
|
else:
|
||||||
|
self.app.notify(f'Removed rating for "{song.title}".')
|
||||||
|
|
||||||
|
self.app.push_screen(RatingModal(f'"{song.title}" By {song.artist}'), process_modal_selection)
|
||||||
|
|
||||||
|
# Just makes the filter appear
|
||||||
|
def action_filter(self) -> None:
|
||||||
|
search_bar: Input = self.query_one(Input)
|
||||||
|
|
||||||
|
if not self.filter_active:
|
||||||
|
search_bar.display = 'block'
|
||||||
|
else:
|
||||||
|
search_bar.display = 'none'
|
||||||
|
|
||||||
|
self.filter_active = not self.filter_active
|
||||||
|
search_bar.focus()
|
||||||
|
|
||||||
|
# Returns index of selected song
|
||||||
|
def _find_selected_song_index(self) -> int:
|
||||||
|
for i, song in enumerate(self.songs):
|
||||||
|
if song.id == self.selected_song_id:
|
||||||
|
return i
|
||||||
Reference in new issue
Block a user