committing here incase navidrome adds an api endpoint for artist bio
This commit is contained in:
1 parent
7b6a1ed30c
commit
3a07071284
2 files changed
+259
-5
No files matched your search
+211
-5
@@ -25,6 +25,7 @@ BITRATE = int(BITRATE) if BITRATE else BITRATE
|
|||||||
|
|
||||||
os.makedirs(".cache/art/", exist_ok=True)
|
os.makedirs(".cache/art/", exist_ok=True)
|
||||||
os.makedirs(".cache/audio/", exist_ok=True)
|
os.makedirs(".cache/audio/", exist_ok=True)
|
||||||
|
os.makedirs(".cache/art/artists/", exist_ok=True)
|
||||||
|
|
||||||
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
|
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ from textual import on, events, work
|
|||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
from textual.app import App, ComposeResult, RenderResult
|
from textual.app import App, ComposeResult, RenderResult
|
||||||
from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input
|
from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.containers import Horizontal, Vertical, Container
|
from textual.containers import Horizontal, Vertical, Container
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
@@ -47,7 +48,9 @@ from textual.widgets.option_list import Option
|
|||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
|
|
||||||
from rich_pixels import Pixels
|
from rich_pixels import Pixels
|
||||||
from PIL import Image
|
from PIL import Image, ImageFile
|
||||||
|
|
||||||
|
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||||
|
|
||||||
u, p = [s.strip() for s in open('auth', 'r').read().split(',')]
|
u, p = [s.strip() for s in open('auth', 'r').read().split(',')]
|
||||||
|
|
||||||
@@ -149,6 +152,90 @@ class ArtistAlbums(Screen):
|
|||||||
app.pop_screen()
|
app.pop_screen()
|
||||||
app.uninstall_screen("artistAlbums")
|
app.uninstall_screen("artistAlbums")
|
||||||
|
|
||||||
|
class ArtistInfoPanel(Widget):
|
||||||
|
|
||||||
|
artist: SubSonic.Artist
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
yield ArtistArt(classes="artistInfoArt")
|
||||||
|
yield Placeholder(classes="artistInfoTextbox")
|
||||||
|
yield Rule()
|
||||||
|
|
||||||
|
def set_artist(self, artist: SubSonic.Artist) -> None:
|
||||||
|
self.artist = artist
|
||||||
|
|
||||||
|
artist_art = self.query_one(ArtistArt)
|
||||||
|
artist_art.get_artist_image(self.artist.id)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
class ArtistAlbums2(Screen):
|
||||||
|
BINDINGS = [
|
||||||
|
('escape', 'die()', "Back")
|
||||||
|
]
|
||||||
|
|
||||||
|
artist: SubSonic.Artist
|
||||||
|
albums: list[SubSonic.Album]
|
||||||
|
|
||||||
|
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 ArtistInfoPanel(classes="artistInfo")
|
||||||
|
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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.albums = conn.getArtistAlbums(self.artist.id)
|
||||||
|
|
||||||
|
artist_info_panel = self.query_one(ArtistInfoPanel)
|
||||||
|
artist_info_panel.set_artist(self.artist)
|
||||||
|
|
||||||
|
albums_displayed = 0
|
||||||
|
album_panels = self.query(AlbumPanel)
|
||||||
|
for album_panel in album_panels:
|
||||||
|
album_panel.set_album(self.albums[albums_displayed])
|
||||||
|
albums_displayed += 1
|
||||||
|
|
||||||
|
if albums_displayed >= len(self.albums) - 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
def action_die(self) -> None:
|
||||||
|
app.pop_screen()
|
||||||
|
app.uninstall_screen("artistAlbums")
|
||||||
|
|
||||||
class Artists(Screen):
|
class Artists(Screen):
|
||||||
|
|
||||||
artists: list[SubSonic.Artist]
|
artists: list[SubSonic.Artist]
|
||||||
@@ -190,6 +277,126 @@ class Artists(Screen):
|
|||||||
if search_value in artist.name.lower():
|
if search_value in artist.name.lower():
|
||||||
artist_list.add_option(Option(artist.name, artist.id))
|
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
|
||||||
|
logging.debug(f'{width=}, {height=}')
|
||||||
|
|
||||||
|
height *= 2
|
||||||
|
|
||||||
|
if width > height:
|
||||||
|
width = height
|
||||||
|
elif height > width:
|
||||||
|
height = width
|
||||||
|
|
||||||
|
return Pixels.from_image(self.art, resize=(width, height))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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:
|
||||||
|
logging.debug(f'ArtistPanel {self.artist.name} was clicked.')
|
||||||
|
app.install_screen(ArtistAlbums2(self.artist), 'artistAlbums')
|
||||||
|
app.push_screen('artistAlbums')
|
||||||
|
|
||||||
|
class Artists2(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 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 Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
app.sub_title = 'Artists2'
|
||||||
|
self.artists = conn.getArtists()
|
||||||
|
|
||||||
|
artists_displayed = 0
|
||||||
|
artist_panels = self.query(ArtistPanel)
|
||||||
|
for artist_panel in artist_panels:
|
||||||
|
artist_panel.set_artist(self.artists[artists_displayed])
|
||||||
|
artists_displayed += 1
|
||||||
|
|
||||||
class Albums(Screen):
|
class Albums(Screen):
|
||||||
|
|
||||||
albums: list[SubSonic.Album]
|
albums: list[SubSonic.Album]
|
||||||
@@ -405,7 +612,7 @@ class CoverArtPixels(Container):
|
|||||||
if self.cover:
|
if self.cover:
|
||||||
width, height = self.size
|
width, height = self.size
|
||||||
|
|
||||||
height += 100
|
height *= 2
|
||||||
|
|
||||||
if width > height:
|
if width > height:
|
||||||
width = height
|
width = height
|
||||||
@@ -440,7 +647,6 @@ class CoverArtPixels(Container):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to get cover art: {e}")
|
logging.error(f"Failed to get cover art: {e}")
|
||||||
self.cover = None
|
self.cover = None
|
||||||
return
|
|
||||||
|
|
||||||
class QueueTable(Widget):
|
class QueueTable(Widget):
|
||||||
|
|
||||||
@@ -610,7 +816,7 @@ class TextualSubpyPlayer(App):
|
|||||||
|
|
||||||
MODES = {
|
MODES = {
|
||||||
"main" : MainView,
|
"main" : MainView,
|
||||||
"artists" : Artists,
|
"artists" : Artists2,
|
||||||
"albums" : Albums,
|
"albums" : Albums,
|
||||||
"favourites" : Favourites,
|
"favourites" : Favourites,
|
||||||
"playlists" : Playlists
|
"playlists" : Playlists
|
||||||
|
|||||||
@@ -34,3 +34,51 @@
|
|||||||
#headerbs {
|
#headerbs {
|
||||||
height: 1;
|
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%;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user