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
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 |
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")
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
+104 -3
View File
@@ -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)