From e06542cb1d4b7662d7d7e166b19aa5db4e9cb7e3 Mon Sep 17 00:00:00 2001 From: x3 Date: Sun, 6 Sep 2026 08:40:59 +0000 Subject: [PATCH] feat: Lyrics-Funktion und Install-Script - Lyrics-Ansicht mit Taste l (lrclib.net API) - Install-Script fuer systemweite Installation - README aktualisiert --- README.md | 9 ++-- install.sh | 22 +++++++++ spotify_cli/client.py | 38 +++++++++++++++ spotify_cli/ui.py | 107 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 169 insertions(+), 7 deletions(-) create mode 100755 install.sh diff --git a/README.md b/README.md index 3bbc677..1d55bd5 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ Terminal-basierter Spotify Client im Stil von ncmpcpp. ```bash cd spotify-cli -pip install -e . +sudo ./install.sh ``` -Oder direkt: +Oder manuell: ```bash -pip install -r requirements.txt -python -m spotify_cli.main +cd spotify-cli +sudo pip install . ``` ### 3. Einloggen @@ -61,6 +61,7 @@ spotify-cli | `r` | Repeat togglen | | `s` | Shuffle togglen | | `/` | Suche oeffnen | +| `l` | Lyrics anzeigen | | `h` oder `Esc` | Zurueck | | `1`-`8` | Schnellauswahl (Nav) | | `L` | Library | diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ce367c0 --- /dev/null +++ b/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== spotify-cli Installation ===" +echo "" + +if [ "$EUID" -eq 0 ]; then + pip install "$SCRIPT_DIR" +else + echo "Installiere systemweit mit sudo..." + sudo pip install "$SCRIPT_DIR" +fi + +echo "" +echo "Installation abgeschlossen!" +echo "" +echo "Benutzung:" +echo " spotify-cli --login # Einloggen" +echo " spotify-cli # Starten" +echo " spotify-cli --logout # Ausloggen" diff --git a/spotify_cli/client.py b/spotify_cli/client.py index 58f9c26..a835a60 100644 --- a/spotify_cli/client.py +++ b/spotify_cli/client.py @@ -197,5 +197,43 @@ class SpotifyClient: return self._get("/me") +class LyricsClient: + LRCLIB_URL = "https://lrclib.net/api" + + def get_lyrics(self, track_name, artist_name, album_name=None, duration_ms=None): + import requests as req + + params = { + "track_name": track_name, + "artist_name": artist_name, + } + if album_name: + params["album_name"] = album_name + if duration_ms: + params["duration"] = duration_ms // 1000 + + try: + resp = req.get(f"{self.LRCLIB_URL}/get", params=params, timeout=5) + if resp.status_code == 200: + data = resp.json() + return data.get("syncedLyrics") or data.get("plainLyrics") + except Exception: + pass + + try: + resp = req.get(f"{self.LRCLIB_URL}/search", params={ + "q": f"{track_name} {artist_name}" + }, timeout=5) + if resp.status_code == 200: + results = resp.json() + if results: + data = results[0] + return data.get("syncedLyrics") or data.get("plainLyrics") + except Exception: + pass + + return None + + class TokenExpired(Exception): pass diff --git a/spotify_cli/ui.py b/spotify_cli/ui.py index 77300ed..47123fb 100644 --- a/spotify_cli/ui.py +++ b/spotify_cli/ui.py @@ -1,7 +1,7 @@ import curses import time import threading -from .client import SpotifyClient, TokenExpired +from .client import SpotifyClient, LyricsClient, TokenExpired from .player import Player @@ -24,6 +24,7 @@ class View: ARTIST_DETAIL = "artist_detail" ARTIST_TOP = "artist_top" LIBRARY = "library" + LYRICS = "lyrics" class UI: @@ -41,6 +42,7 @@ class UI: def __init__(self, stdscr, client: SpotifyClient): self.stdscr = stdscr self.client = client + self.lyrics_client = LyricsClient() self.player = Player(client) self.current_view = View.HOME self.nav_index = 0 @@ -68,6 +70,9 @@ class UI: self.context_uri = None self.focus = FOCUS_CONTENT self.force_refresh = False + self.lyrics_data = [] + self.lyrics_scroll = 0 + self.lyrics_track_info = None curses.curs_set(0) curses.use_default_colors() @@ -106,6 +111,11 @@ class UI: self.stdscr.refresh() return + if self.current_view == View.LYRICS: + self._draw_lyrics(h, w) + self.stdscr.refresh() + return + nav_w = 18 status_h = 1 now_h = 3 @@ -257,12 +267,46 @@ class UI: elif self.focus == FOCUS_NAV: keys = "j/k:Move Enter:Select Tab:Content q:Quit" else: - keys = "j/k:Move Enter:Select Space:Play n/p:Next +/-:Vol Tab:Nav q:Quit" + keys = "j/k:Move Enter:Select Space:Play n/p:Next +/-:Vol l:Lyrics Tab:Nav q:Quit" try: self.stdscr.addnstr(y, x, keys.ljust(w), w-1, curses.color_pair(7)) except curses.error: pass + def _draw_lyrics(self, h, w): + try: + self.stdscr.addnstr(0, 0, " LYRICS".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + if self.lyrics_track_info: + info = self.lyrics_track_info + header = f" {info['artists']} - {info['name']}" + try: + self.stdscr.addnstr(1, 0, header[:w-1], w-1, curses.color_pair(4) | curses.A_BOLD) + except curses.error: + pass + + visible = h - 3 + start = self.lyrics_scroll + end = min(start + visible, len(self.lyrics_data)) + + for i in range(start, end): + row = 2 + (i - start) + if row >= h - 1: + break + line = self.lyrics_data[i] + try: + self.stdscr.addnstr(row, 0, line[:w-1], w-1, curses.color_pair(1)) + except curses.error: + pass + + bar = "q/Esc:Zurueck j/k:Scroll PgUp/PgDn:Page" + try: + self.stdscr.addnstr(h-1, 0, bar.ljust(w), w-1, curses.color_pair(7)) + except curses.error: + pass + def _make_progress_bar(self, current, total, width): if total == 0: return "" @@ -670,6 +714,32 @@ class UI: self.show_message(f"Fehler: {e}") self.loading = False + def load_lyrics(self): + self.loading = True + self.draw() + self.lyrics_data = [] + self.lyrics_scroll = 0 + self.lyrics_track_info = None + + info = self.player.get_track_info() + if not info: + self.lyrics_data = ["Kein Track abgespielt."] + self.loading = False + return + + self.lyrics_track_info = info + track_name = info["name"] + artist_name = info["artists"] + album_name = info.get("album", "") + duration_ms = info.get("duration_ms") + + lyrics_text = self.lyrics_client.get_lyrics(track_name, artist_name, album_name, duration_ms) + if lyrics_text: + self.lyrics_data = lyrics_text.split("\n") + else: + self.lyrics_data = [f"Keine Lyrics gefunden fuer:", f"{artist_name} - {track_name}"] + self.loading = False + def do_search(self, query): if not query: return @@ -783,7 +853,9 @@ class UI: self.navigate_to(View.ARTISTS) def handle_back(self): - if self.current_view in (View.PLAYLIST_DETAIL, View.LIBRARY): + if self.current_view == View.LYRICS: + self.navigate_to(View.HOME) + elif self.current_view in (View.PLAYLIST_DETAIL, View.LIBRARY): self.navigate_to(View.PLAYLISTS if self.current_view == View.PLAYLIST_DETAIL else View.HOME) elif self.current_view in (View.ALBUM_DETAIL, View.ARTIST_DETAIL): self.navigate_to(View.HOME) @@ -827,6 +899,10 @@ class UI: self.search_query += chr(key) def _handle_key(self, key): + if self.current_view == View.LYRICS: + self._handle_lyrics_key(key) + return + content_count = len(self.content_items) nav_count = len(self.NAV_ITEMS) @@ -846,6 +922,28 @@ class UI: else: self._handle_content_key(key, content_count) + def _handle_lyrics_key(self, key): + lyrics_count = len(self.lyrics_data) + h, w = self.stdscr.getmaxyx() + visible = h - 3 + + if key == ord("q") or key == 27 or key == ord("l"): + self.navigate_to(View.HOME) + + elif key == ord("j") or key == curses.KEY_DOWN: + if self.lyrics_scroll < max(0, lyrics_count - visible): + self.lyrics_scroll += 1 + + elif key == ord("k") or key == curses.KEY_UP: + if self.lyrics_scroll > 0: + self.lyrics_scroll -= 1 + + elif key == curses.KEY_PPAGE: + self.lyrics_scroll = max(0, self.lyrics_scroll - visible) + + elif key == curses.KEY_NPAGE: + self.lyrics_scroll = min(max(0, lyrics_count - visible), self.lyrics_scroll + visible) + def _handle_nav_key(self, key, nav_count): if key == ord("j") or key == curses.KEY_DOWN: if self.nav_index < nav_count - 1: @@ -993,6 +1091,9 @@ class UI: elif key == ord("h") or key == 27: self.handle_back() + elif key == ord("l"): + self.navigate_to(View.LYRICS) + elif key == ord("L"): self.navigate_to(View.LIBRARY)