feat: Lyrics-Funktion und Install-Script

- Lyrics-Ansicht mit Taste l (lrclib.net API)
- Install-Script fuer systemweite Installation
- README aktualisiert
This commit is contained in:
x3
2026-09-06 08:40:59 +00:00
parent 68f6e0ef84
commit e06542cb1d
4 changed files with 169 additions and 7 deletions
+5 -4
View File
@@ -21,14 +21,14 @@ Terminal-basierter Spotify Client im Stil von ncmpcpp.
```bash ```bash
cd spotify-cli cd spotify-cli
pip install -e . sudo ./install.sh
``` ```
Oder direkt: Oder manuell:
```bash ```bash
pip install -r requirements.txt cd spotify-cli
python -m spotify_cli.main sudo pip install .
``` ```
### 3. Einloggen ### 3. Einloggen
@@ -61,6 +61,7 @@ spotify-cli
| `r` | Repeat togglen | | `r` | Repeat togglen |
| `s` | Shuffle togglen | | `s` | Shuffle togglen |
| `/` | Suche oeffnen | | `/` | Suche oeffnen |
| `l` | Lyrics anzeigen |
| `h` oder `Esc` | Zurueck | | `h` oder `Esc` | Zurueck |
| `1`-`8` | Schnellauswahl (Nav) | | `1`-`8` | Schnellauswahl (Nav) |
| `L` | Library | | `L` | Library |
Executable
+22
View File
@@ -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"
+38
View File
@@ -197,5 +197,43 @@ class SpotifyClient:
return self._get("/me") 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): class TokenExpired(Exception):
pass pass
+104 -3
View File
@@ -1,7 +1,7 @@
import curses import curses
import time import time
import threading import threading
from .client import SpotifyClient, TokenExpired from .client import SpotifyClient, LyricsClient, TokenExpired
from .player import Player from .player import Player
@@ -24,6 +24,7 @@ class View:
ARTIST_DETAIL = "artist_detail" ARTIST_DETAIL = "artist_detail"
ARTIST_TOP = "artist_top" ARTIST_TOP = "artist_top"
LIBRARY = "library" LIBRARY = "library"
LYRICS = "lyrics"
class UI: class UI:
@@ -41,6 +42,7 @@ class UI:
def __init__(self, stdscr, client: SpotifyClient): def __init__(self, stdscr, client: SpotifyClient):
self.stdscr = stdscr self.stdscr = stdscr
self.client = client self.client = client
self.lyrics_client = LyricsClient()
self.player = Player(client) self.player = Player(client)
self.current_view = View.HOME self.current_view = View.HOME
self.nav_index = 0 self.nav_index = 0
@@ -68,6 +70,9 @@ class UI:
self.context_uri = None self.context_uri = None
self.focus = FOCUS_CONTENT self.focus = FOCUS_CONTENT
self.force_refresh = False self.force_refresh = False
self.lyrics_data = []
self.lyrics_scroll = 0
self.lyrics_track_info = None
curses.curs_set(0) curses.curs_set(0)
curses.use_default_colors() curses.use_default_colors()
@@ -106,6 +111,11 @@ class UI:
self.stdscr.refresh() self.stdscr.refresh()
return return
if self.current_view == View.LYRICS:
self._draw_lyrics(h, w)
self.stdscr.refresh()
return
nav_w = 18 nav_w = 18
status_h = 1 status_h = 1
now_h = 3 now_h = 3
@@ -257,12 +267,46 @@ class UI:
elif self.focus == FOCUS_NAV: elif self.focus == FOCUS_NAV:
keys = "j/k:Move Enter:Select Tab:Content q:Quit" keys = "j/k:Move Enter:Select Tab:Content q:Quit"
else: 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: try:
self.stdscr.addnstr(y, x, keys.ljust(w), w-1, curses.color_pair(7)) self.stdscr.addnstr(y, x, keys.ljust(w), w-1, curses.color_pair(7))
except curses.error: except curses.error:
pass 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): def _make_progress_bar(self, current, total, width):
if total == 0: if total == 0:
return "" return ""
@@ -670,6 +714,32 @@ class UI:
self.show_message(f"Fehler: {e}") self.show_message(f"Fehler: {e}")
self.loading = False 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): def do_search(self, query):
if not query: if not query:
return return
@@ -783,7 +853,9 @@ class UI:
self.navigate_to(View.ARTISTS) self.navigate_to(View.ARTISTS)
def handle_back(self): 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) 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): elif self.current_view in (View.ALBUM_DETAIL, View.ARTIST_DETAIL):
self.navigate_to(View.HOME) self.navigate_to(View.HOME)
@@ -827,6 +899,10 @@ class UI:
self.search_query += chr(key) self.search_query += chr(key)
def _handle_key(self, key): def _handle_key(self, key):
if self.current_view == View.LYRICS:
self._handle_lyrics_key(key)
return
content_count = len(self.content_items) content_count = len(self.content_items)
nav_count = len(self.NAV_ITEMS) nav_count = len(self.NAV_ITEMS)
@@ -846,6 +922,28 @@ class UI:
else: else:
self._handle_content_key(key, content_count) 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): def _handle_nav_key(self, key, nav_count):
if key == ord("j") or key == curses.KEY_DOWN: if key == ord("j") or key == curses.KEY_DOWN:
if self.nav_index < nav_count - 1: if self.nav_index < nav_count - 1:
@@ -993,6 +1091,9 @@ class UI:
elif key == ord("h") or key == 27: elif key == ord("h") or key == 27:
self.handle_back() self.handle_back()
elif key == ord("l"):
self.navigate_to(View.LYRICS)
elif key == ord("L"): elif key == ord("L"):
self.navigate_to(View.LIBRARY) self.navigate_to(View.LIBRARY)