From 352ed0287144978fc0eb0502c44d2d40cd6659eb Mon Sep 17 00:00:00 2001 From: x3 Date: Sun, 6 Sep 2026 08:08:56 +0000 Subject: [PATCH] feat: terminal spotify client (ncmpcpp-style) --- .gitignore | 11 + README.md | 93 ++++ requirements.txt | 1 + setup.py | 17 + spotify_cli/__init__.py | 0 spotify_cli/__main__.py | 2 + spotify_cli/auth.py | 168 ++++++++ spotify_cli/client.py | 201 +++++++++ spotify_cli/config.py | 46 ++ spotify_cli/main.py | 44 ++ spotify_cli/player.py | 166 ++++++++ spotify_cli/ui.py | 924 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1673 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 spotify_cli/__init__.py create mode 100644 spotify_cli/__main__.py create mode 100644 spotify_cli/auth.py create mode 100644 spotify_cli/client.py create mode 100644 spotify_cli/config.py create mode 100644 spotify_cli/main.py create mode 100644 spotify_cli/player.py create mode 100644 spotify_cli/ui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8275b4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.env +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f9fde4 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# spotify-cli + +Terminal-basierter Spotify Client im Stil von ncmpcpp. + +## Voraussetzungen + +- Python 3.8+ +- Spotify Free oder Premium Account +- Spotify App in den [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) angelegt + +## Setup + +### 1. Spotify App erstellen + +1. Gehe zu https://developer.spotify.com/dashboard +2. Erstelle eine neue App +3. Setze den Redirect URI auf `http://localhost:8888/callback` +4. Kopiere die **Client ID** + +### 2. Installation + +```bash +cd spotify-cli +pip install -e . +``` + +Oder direkt: + +```bash +pip install -r requirements.txt +python -m spotify_cli.main +``` + +### 3. Einloggen + +```bash +spotify-cli --login +``` + +Beim ersten Start wirst du nach deiner Client ID gefragt. Der Browser öffnet sich automatisch zur Spotify-Authentifizierung. + +## Benutzung + +```bash +spotify-cli +``` + +### Tastenkombinationen + +| Taste | Aktion | +|-------|--------| +| `j`/`k` oder `↑`/`↓` | Navigieren | +| `Enter` | Auswaehlen / Abspielen | +| `Space` | Play / Pause | +| `n` | Naechster Track | +| `p` | Vorheriger Track | +| `+`/`-` | Lautstaerke | +| `r` | Repeat togglen | +| `s` | Shuffle togglen | +| `/` | Suche oeffnen | +| `h` oder `Esc` | Zurueck | +| `1`-`8` | Schnellauswahl (Nav) | +| `L` | Library | +| `q` | Beenden | + +### Views + +- **Home**: Kuerzlich gespielte Tracks +- **Playlists**: Alle deine Playlists +- **Liked Songs**: Deine gelikten Songs +- **Recently Played**: Verlauf +- **Library**: Liked Songs, Alben, Kuenstler +- **Queue**: Warteschlange +- **Devices**: Geraete wechseln +- **Search**: Songs, Alben, Kuenstler, Playlists suchen + +## Config + +Config-Datei: `~/.config/spotify-cli/config.json` + +```json +{ + "client_id": "deine-client-id" +} +``` + +Token wird in `~/.config/spotify-cli/token.json` gespeichert. + +## Logout + +```bash +spotify-cli --logout +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0eb8cae --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9514d06 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + +setup( + name="spotify-cli", + version="1.0.0", + description="A terminal-based Spotify client similar to ncmpcpp", + packages=find_packages(), + install_requires=[ + "requests>=2.31.0", + ], + entry_points={ + "console_scripts": [ + "spotify-cli=spotify_cli.main:main", + ], + }, + python_requires=">=3.8", +) diff --git a/spotify_cli/__init__.py b/spotify_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotify_cli/__main__.py b/spotify_cli/__main__.py new file mode 100644 index 0000000..c32699f --- /dev/null +++ b/spotify_cli/__main__.py @@ -0,0 +1,2 @@ +from .main import main +main() diff --git a/spotify_cli/auth.py b/spotify_cli/auth.py new file mode 100644 index 0000000..b0e6bae --- /dev/null +++ b/spotify_cli/auth.py @@ -0,0 +1,168 @@ +import hashlib +import base64 +import secrets +import webbrowser +import time +import json +from urllib.parse import urlencode +import requests + +from .config import load_config, save_token, load_token, clear_token + +SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize" +SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token" +SCOPES = [ + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "playlist-read-private", + "playlist-read-collaborative", + "user-library-read", + "user-read-recently-played", + "user-top-read", + "streaming", +] + +def _generate_pkce(): + code_verifier = secrets.token_urlsafe(64)[:128] + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return code_verifier, code_challenge + +class SpotifyAuth: + def __init__(self): + self.cfg = load_config() + self.client_id = self.cfg.get("client_id", "") + self.token_data = load_token() + + def _is_token_valid(self): + if not self.token_data: + return False + return time.time() < self.token_data.get("expires_at", 0) - 60 + + def get_token(self): + if self._is_token_valid(): + return self.token_data["access_token"] + + if self.token_data and self.token_data.get("refresh_token"): + refreshed = self._refresh(self.token_data["refresh_token"]) + if refreshed: + return self.token_data["access_token"] + + return self._full_auth() + + def _refresh(self, refresh_token): + resp = requests.post(SPOTIFY_TOKEN_URL, data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self.client_id, + }) + if resp.status_code == 200: + data = resp.json() + self.token_data = { + "access_token": data["access_token"], + "refresh_token": data.get("refresh_token", refresh_token), + "expires_at": time.time() + data["expires_in"], + } + save_token(self.token_data) + return True + clear_token() + self.token_data = None + return False + + def _full_auth(self): + if not self.client_id: + self.client_id = input("Spotify Client ID: ").strip() + self.cfg["client_id"] = self.client_id + from .config import save_config + save_config(self.cfg) + + code_verifier, code_challenge = _generate_pkce() + state = secrets.token_urlsafe(16) + + params = { + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": "http://localhost:8888/callback", + "code_challenge_method": "S256", + "code_challenge": code_challenge, + "state": state, + "scope": " ".join(SCOPES), + } + + auth_url = f"{SPOTIFY_AUTH_URL}?{urlencode(params)}" + print(f"\nOeffne Browser fuer Spotify Login...") + print(f"Falls der Browser nicht öffnet, nutze diese URL:\n{auth_url}\n") + webbrowser.open(auth_url) + + code = self._wait_for_callback() + if not code: + print("Authentifizierung fehlgeschlagen.") + return None + + return self._exchange_code(code, code_verifier) + + def _wait_for_callback(self, timeout=300): + from http.server import HTTPServer, BaseHTTPRequestHandler + import threading + + received_code = {"code": None, "state": None} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if "/callback" in self.path: + from urllib.parse import urlparse, parse_qs + query = urlparse(self.path).query + params = parse_qs(query) + received_code["code"] = params.get("code", [None])[0] + received_code["state"] = params.get("state", [None])[0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Authentifizierung erfolgreich! Du kannst das Fenster schliessen.

") + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass + + server = HTTPServer(("localhost", 8888), Handler) + server.timeout = timeout + + start = time.time() + while time.time() - start < timeout: + server.handle_request() + if received_code["code"]: + break + + server.server_close() + return received_code["code"] + + def _exchange_code(self, code, code_verifier): + resp = requests.post(SPOTIFY_TOKEN_URL, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost:8888/callback", + "client_id": self.client_id, + "code_verifier": code_verifier, + }) + + if resp.status_code == 200: + data = resp.json() + self.token_data = { + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + "expires_at": time.time() + data["expires_in"], + } + save_token(self.token_data) + return data["access_token"] + + print(f"Token-Austausch fehlgeschlagen: {resp.status_code}") + print(resp.text) + return None + + def logout(self): + clear_token() + self.token_data = None + print("Ausgelogged.") diff --git a/spotify_cli/client.py b/spotify_cli/client.py new file mode 100644 index 0000000..58f9c26 --- /dev/null +++ b/spotify_cli/client.py @@ -0,0 +1,201 @@ +import requests +from typing import Optional + +BASE_URL = "https://api.spotify.com/v1" + + +class SpotifyClient: + def __init__(self, token: str): + self.token = token + self.headers = {"Authorization": f"Bearer {token}"} + + def _get(self, endpoint, params=None): + resp = requests.get(f"{BASE_URL}{endpoint}", headers=self.headers, params=params) + if resp.status_code == 401: + raise TokenExpired() + if resp.status_code == 429: + import time + retry = int(resp.headers.get("Retry-After", 1)) + time.sleep(retry) + return self._get(endpoint, params) + resp.raise_for_status() + return resp.json() + + def _put(self, endpoint, data=None): + resp = requests.put(f"{BASE_URL}{endpoint}", headers=self.headers, json=data) + if resp.status_code == 401: + raise TokenExpired() + if resp.status_code == 429: + import time + retry = int(resp.headers.get("Retry-After", 1)) + time.sleep(retry) + return self._put(endpoint, data) + if resp.status_code == 204: + return None + resp.raise_for_status() + return resp.json() if resp.text else None + + def _post(self, endpoint, data=None): + resp = requests.post(f"{BASE_URL}{endpoint}", headers=self.headers, json=data) + if resp.status_code == 401: + raise TokenExpired() + if resp.status_code == 429: + import time + retry = int(resp.headers.get("Retry-After", 1)) + time.sleep(retry) + return self._post(endpoint, data) + if resp.status_code == 204: + return None + resp.raise_for_status() + return resp.json() if resp.text else None + + def _delete(self, endpoint, data=None): + resp = requests.delete(f"{BASE_URL}{endpoint}", headers=self.headers, json=data) + if resp.status_code == 401: + raise TokenExpired() + if resp.status_code == 429: + import time + retry = int(resp.headers.get("Retry-After", 1)) + time.sleep(retry) + return self._delete(endpoint, data) + if resp.status_code == 204: + return None + resp.raise_for_status() + return resp.json() if resp.text else None + + # --- Playback --- + def get_playback(self): + return self._get("/me/player") + + def get_current_track(self): + return self._get("/me/player/currently-playing") + + def play(self, context_uri=None, uris=None, offset=None, position_ms=None): + data = {} + if context_uri: + data["context_uri"] = context_uri + if uris: + data["uris"] = uris + if offset: + data["offset"] = offset + if position_ms is not None: + data["position_ms"] = position_ms + return self._put("/me/player/play", data) + + def pause(self): + return self._put("/me/player/pause") + + def next_track(self): + return self._post("/me/player/next") + + def previous_track(self): + return self._post("/me/player/previous") + + def seek(self, position_ms): + return self._put(f"/me/player/seek?position_ms={position_ms}") + + def set_volume(self, volume): + return self._put(f"/me/player/volume?volume={volume}") + + def set_repeat(self, state): + return self._put(f"/me/player/repeat?state={state}") + + def set_shuffle(self, state): + return self._put(f"/me/player/shuffle?state={state}") + + def transfer_playback(self, device_id, force_play=True): + return self._put("/me/player", { + "device_ids": [device_id], + "play": force_play, + }) + + def get_devices(self): + return self._get("/me/player/devices") + + # --- Player Info --- + def get_recently_played(self, limit=20): + return self._get("/me/player/recently-played", {"limit": limit}) + + def get_queue(self): + return self._get("/me/player/queue") + + def add_to_queue(self, uri): + return self._post(f"/me/player/queue?uri={uri}") + + # --- Library --- + def get_saved_tracks(self, limit=50, offset=0): + return self._get("/me/tracks", {"limit": limit, "offset": offset}) + + def get_saved_albums(self, limit=50, offset=0): + return self._get("/me/albums", {"limit": limit, "offset": offset}) + + def get_saved_artists(self, limit=50, offset=0): + return self._get("/me/following", {"type": "artist", "limit": limit, "offset": offset}) + + def save_track(self, track_ids): + return self._put("/me/tracks", {"ids": track_ids}) + + def remove_saved_track(self, track_ids): + return self._delete("/me/tracks", {"ids": track_ids}) + + def check_saved_track(self, track_ids): + return self._get(f"/me/tracks/contains?ids={','.join(track_ids)}") + + # --- Playlists --- + def get_playlists(self, limit=50, offset=0): + return self._get("/me/playlists", {"limit": limit, "offset": offset}) + + def get_playlist(self, playlist_id): + return self._get(f"/playlists/{playlist_id}") + + def get_playlist_tracks(self, playlist_id, limit=100, offset=0): + return self._get(f"/playlists/{playlist_id}/tracks", {"limit": limit, "offset": offset}) + + def create_playlist(self, name, description="", public=False): + user = self.get_current_user() + return self._post(f"/users/{user['id']}/playlists", { + "name": name, + "description": description, + "public": public, + }) + + def add_to_playlist(self, playlist_id, uris): + return self._post(f"/playlists/{playlist_id}/tracks", {"uris": uris}) + + def remove_from_playlist(self, playlist_id, track_ids): + return self._delete(f"/playlists/{playlist_id}/tracks", { + "tracks": [{"id": tid} for tid in track_ids], + }) + + # --- Albums --- + def get_album(self, album_id): + return self._get(f"/albums/{album_id}") + + def get_album_tracks(self, album_id, limit=50, offset=0): + return self._get(f"/albums/{album_id}/tracks", {"limit": limit, "offset": offset}) + + # --- Artists --- + def get_artist(self, artist_id): + return self._get(f"/artists/{artist_id}") + + def get_artist_top_tracks(self, artist_id, country="US"): + return self._get(f"/artists/{artist_id}/top-tracks", {"country": country}) + + def get_artist_albums(self, artist_id, limit=50, offset=0): + return self._get(f"/artists/{artist_id}/albums", { + "limit": limit, "offset": offset, "include_groups": "album,single" + }) + + # --- Search --- + def search(self, query, types="track,album,artist,playlist", limit=20, offset=0): + return self._get("/search", { + "q": query, "type": types, "limit": limit, "offset": offset + }) + + # --- User --- + def get_current_user(self): + return self._get("/me") + + +class TokenExpired(Exception): + pass diff --git a/spotify_cli/config.py b/spotify_cli/config.py new file mode 100644 index 0000000..a29d650 --- /dev/null +++ b/spotify_cli/config.py @@ -0,0 +1,46 @@ +import json +import os +from pathlib import Path + +CONFIG_DIR = Path.home() / ".config" / "spotify-cli" +CONFIG_FILE = CONFIG_DIR / "config.json" +TOKEN_FILE = CONFIG_DIR / "token.json" + +DEFAULT_CONFIG = { + "client_id": "", + "default_device": None, + "volume_step": 5, +} + +def ensure_config_dir(): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +def load_config(): + ensure_config_dir() + if CONFIG_FILE.exists(): + with open(CONFIG_FILE) as f: + user_cfg = json.load(f) + cfg = {**DEFAULT_CONFIG, **user_cfg} + return cfg + return DEFAULT_CONFIG.copy() + +def save_config(cfg: dict): + ensure_config_dir() + with open(CONFIG_FILE, "w") as f: + json.dump(cfg, f, indent=2) + +def load_token(): + ensure_config_dir() + if TOKEN_FILE.exists(): + with open(TOKEN_FILE) as f: + return json.load(f) + return None + +def save_token(token: dict): + ensure_config_dir() + with open(TOKEN_FILE, "w") as f: + json.dump(token, f, indent=2) + +def clear_token(): + if TOKEN_FILE.exists(): + TOKEN_FILE.unlink() diff --git a/spotify_cli/main.py b/spotify_cli/main.py new file mode 100644 index 0000000..a76c86f --- /dev/null +++ b/spotify_cli/main.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import sys +import curses +import argparse + +from .auth import SpotifyAuth +from .client import SpotifyClient +from .ui import UI + + +def main(): + parser = argparse.ArgumentParser(description="spotify-cli - Terminal Spotify Client") + parser.add_argument("--login", action="store_true", help="Neu einloggen") + parser.add_argument("--logout", action="store_true", help="Ausloggen") + args = parser.parse_args() + + auth = SpotifyAuth() + + if args.logout: + auth.logout() + return + + if args.login: + auth._full_auth() + return + + token = auth.get_token() + if not token: + print("Nicht eingelogged. Fuehre 'spotify-cli --login' aus.") + sys.exit(1) + + client = SpotifyClient(token) + + try: + client.get_current_user() + except Exception: + print("Token abgelaufen. Bitte erneut einloggen: spotify-cli --login") + sys.exit(1) + + curses.wrapper(lambda stdscr: UI(stdscr, client).run()) + + +if __name__ == "__main__": + main() diff --git a/spotify_cli/player.py b/spotify_cli/player.py new file mode 100644 index 0000000..1d9e736 --- /dev/null +++ b/spotify_cli/player.py @@ -0,0 +1,166 @@ +import threading +import time +from .client import SpotifyClient + + +class Player: + def __init__(self, client: SpotifyClient): + self.client = client + self._poll_thread = None + self._running = False + self._callbacks = [] + self.current_playback = None + self.current_track = None + self.devices = [] + + def register_callback(self, cb): + self._callbacks.append(cb) + + def _notify(self): + for cb in self._callbacks: + try: + cb(self.current_playback, self.current_track) + except Exception: + pass + + def start_polling(self, interval=2): + self._running = True + self._poll_thread = threading.Thread(target=self._poll_loop, args=(interval,), daemon=True) + self._poll_thread.start() + + def stop_polling(self): + self._running = False + if self._poll_thread: + self._poll_thread.join(timeout=3) + + def _poll_loop(self, interval): + while self._running: + try: + self.refresh() + except Exception: + pass + time.sleep(interval) + + def refresh(self): + try: + self.current_playback = self.client.get_playback() + if self.current_playback: + self.devices = self.current_playback.get("devices", []) + except Exception: + self.current_playback = None + + try: + self.current_track = self.client.get_current_track() + except Exception: + self.current_track = None + + self._notify() + + def toggle_play(self): + if not self.current_playback: + return + if self.current_playback.get("is_playing"): + self.client.pause() + else: + self.client.play() + time.sleep(0.3) + self.refresh() + + def next(self): + self.client.next_track() + time.sleep(0.5) + self.refresh() + + def previous(self): + self.client.previous_track() + time.sleep(0.5) + self.refresh() + + def seek(self, position_ms): + self.client.seek(position_ms) + time.sleep(0.3) + self.refresh() + + def set_volume(self, volume): + volume = max(0, min(100, volume)) + self.client.set_volume(volume) + self.refresh() + + def volume_up(self, step=5): + vol = self.get_volume() + self.set_volume(vol + step) + + def volume_down(self, step=5): + vol = self.get_volume() + self.set_volume(vol - step) + + def get_volume(self): + if self.current_playback and self.current_playback.get("device"): + return self.current_playback["device"].get("volume_percent", 50) + return 50 + + def toggle_repeat(self): + if not self.current_playback: + return + state = self.current_playback.get("repeat_state", "off") + new_state = "track" if state == "off" else "off" + self.client.set_repeat(new_state) + self.refresh() + + def toggle_shuffle(self): + if not self.current_playback: + return + state = not self.current_playback.get("shuffle_state", False) + self.client.set_shuffle(state) + self.refresh() + + def play_context(self, context_uri, offset=None): + self.client.play(context_uri=context_uri, offset=offset) + time.sleep(0.5) + self.refresh() + + def play_tracks(self, uris, offset=0): + self.client.play(uris=uris, offset=offset) + time.sleep(0.5) + self.refresh() + + def play_track(self, uri): + self.client.play(uris=[uri]) + time.sleep(0.5) + self.refresh() + + def transfer_to(self, device_id): + self.client.transfer_playback(device_id) + time.sleep(0.5) + self.refresh() + + def get_track_info(self): + if not self.current_track or not self.current_track.get("item"): + return None + track = self.current_track["item"] + artists = ", ".join(a["name"] for a in track.get("artists", [])) + album = track.get("album", {}).get("name", "") + progress = self.current_track.get("progress_ms", 0) + duration = track.get("duration_ms", 0) + return { + "name": track["name"], + "artists": artists, + "album": album, + "duration_ms": duration, + "progress_ms": progress, + "uri": track["uri"], + "is_playing": self.current_track.get("is_playing", False), + "image": track.get("album", {}).get("images", [{}])[0].get("url") if track.get("album", {}).get("images") else None, + } + + def get_playback_state(self): + if not self.current_playback: + return None + return { + "is_playing": self.current_playback.get("is_playing", False), + "repeat_state": self.current_playback.get("repeat_state", "off"), + "shuffle_state": self.current_playback.get("shuffle_state", False), + "volume": self.current_playback.get("device", {}).get("volume_percent", 50), + "device": self.current_playback.get("device", {}).get("name", "Unknown"), + "device_type": self.current_playback.get("device", {}).get("type", ""), + } diff --git a/spotify_cli/ui.py b/spotify_cli/ui.py new file mode 100644 index 0000000..c564360 --- /dev/null +++ b/spotify_cli/ui.py @@ -0,0 +1,924 @@ +import curses +import time +import threading +from .client import SpotifyClient, TokenExpired +from .player import Player + + +class View: + HOME = "home" + PLAYLISTS = "playlists" + PLAYLIST_DETAIL = "playlist_detail" + LIKED_SONGS = "liked_songs" + RECENT = "recent" + ALBUMS = "albums" + ARTISTS = "artists" + SEARCH = "search" + QUEUE = "queue" + DEVICES = "devices" + ALBUM_DETAIL = "album_detail" + ARTIST_DETAIL = "artist_detail" + ARTIST_TOP = "artist_top" + LIBRARY = "library" + + +class UI: + NAV_ITEMS = [ + (View.HOME, "Home"), + (View.PLAYLISTS, "Playlists"), + (View.LIKED_SONGS, "Liked Songs"), + (View.RECENT, "Recently Played"), + (View.LIBRARY, "Library"), + (View.QUEUE, "Queue"), + (View.DEVICES, "Devices"), + (View.SEARCH, "Search"), + ] + + def __init__(self, stdscr, client: SpotifyClient): + self.stdscr = stdscr + self.client = client + self.player = Player(client) + self.current_view = View.HOME + self.nav_index = 0 + self.content_scroll = 0 + self.content_items = [] + self.selected_index = 0 + self.running = True + self.message = "" + self.message_time = 0 + self.search_query = "" + self.search_mode = False + self.loading = False + self.playlists_cache = [] + self.liked_offset = 0 + self.recent_data = [] + self.devices_cache = [] + self.albums_cache = [] + self.artists_cache = [] + self.search_results = {} + self.playlist_tracks = [] + self.album_tracks = [] + self.artist_data = None + self.artist_albums = [] + self.artist_top_tracks = [] + self.context_uri = None + self.last_key_time = 0 + + curses.curs_set(0) + curses.use_default_colors() + curses.start_color() + curses.init_pair(1, curses.COLOR_WHITE, -1) + curses.init_pair(2, curses.COLOR_GREEN, -1) + curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_GREEN) + curses.init_pair(4, curses.COLOR_CYAN, -1) + curses.init_pair(5, curses.COLOR_YELLOW, -1) + curses.init_pair(6, curses.COLOR_MAGENTA, -1) + curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLUE) + curses.init_pair(8, curses.COLOR_GREEN, curses.COLOR_BLUE) + self.stdscr.timeout(500) + + self.player.start_polling(interval=2) + + def stop(self): + self.running = False + self.player.stop_polling() + + def show_message(self, msg, duration=3): + self.message = msg + self.message_time = time.time() + duration + + def draw(self): + self.stdscr.erase() + h, w = self.stdscr.getmaxyx() + + if h < 10 or w < 40: + self.stdscr.addstr(0, 0, "Fenster zu klein! (min 40x10)") + self.stdscr.refresh() + return + + nav_w = 18 + status_h = 1 + now_h = 3 + bar_h = 1 + + self._draw_status_bar(0, 0, w, status_h) + self._draw_nav(status_h, 0, nav_w, h - status_h - now_h - bar_h) + self._draw_content(status_h, nav_w, w - nav_w, h - status_h - now_h - bar_h) + self._draw_now_playing(h - now_h - bar_h, 0, w, now_h) + self._draw_bar(h - bar_h, 0, w, bar_h) + + self.stdscr.refresh() + + def _draw_status_bar(self, y, x, w, h): + try: + state = self.player.get_playback_state() + if state: + play = " Playing" if state["is_playing"] else " Paused" + rep = f" R:{state['repeat_state']}" + shuf = f" S:{'on' if state['shuffle_state'] else 'off'}" + vol = f" Vol:{state['volume']}%" + dev = f" {state['device']}" + left = f"spotify-cli {play}{rep}{shuf}{vol}" + right = dev + pad = w - len(left) - len(right) - 2 + line = left + " " * max(pad, 1) + right + else: + line = " spotify-cli -- No playback device" + self.stdscr.addnstr(y, x, line[:w-1], w-1, curses.color_pair(3) | curses.A_BOLD) + except Exception: + self.stdscr.addnstr(y, x, " spotify-cli", w-1, curses.color_pair(3) | curses.A_BOLD) + + def _draw_nav(self, y, x, w, h): + try: + self.stdscr.addnstr(y, x, " NAV".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + for i, (vid, label) in enumerate(self.NAV_ITEMS): + if i + 1 >= h: + break + try: + prefix = "> " if i == self.nav_index else " " + attr = curses.color_pair(8) if i == self.nav_index else curses.color_pair(1) + if i == self.nav_index: + attr |= curses.A_BOLD + line = f"{prefix}{label}" + self.stdscr.addnstr(y + 1 + i, x, line.ljust(w), w-1, attr) + except curses.error: + pass + + def _draw_content(self, y, x, w, h): + try: + self.stdscr.addnstr(y, x, "".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + header = self._get_view_header() + try: + self.stdscr.addnstr(y, x + 1, header[:w-2], w-2, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + if self.loading: + try: + self.stdscr.addstr(y + 2, x + 2, "Laden...", curses.color_pair(5)) + except curses.error: + pass + return + + visible_items = h - 1 + start = self.content_scroll + end = min(start + visible_items, len(self.content_items)) + + for i in range(start, end): + row = y + 1 + (i - start) + if row >= y + h: + break + item = self.content_items[i] + is_selected = i == self.selected_index + try: + prefix = "> " if is_selected else " " + if isinstance(item, dict): + text = self._format_item(item) + else: + text = str(item) + line = f"{prefix}{text}" + attr = curses.color_pair(2) if is_selected else curses.color_pair(1) + if is_selected: + attr |= curses.A_BOLD + self.stdscr.addnstr(row, x + 1, line[:w-2], w-2, attr) + except curses.error: + pass + + if not self.content_items and not self.loading: + msg = self._get_empty_message() + try: + self.stdscr.addstr(y + 2, x + 2, msg, curses.color_pair(5)) + except curses.error: + pass + + def _draw_now_playing(self, y, x, w, h): + info = self.player.get_track_info() + try: + self.stdscr.addnstr(y, x, "".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + try: + self.stdscr.addnstr(y, x, " NOW PLAYING".ljust(w), w-1, curses.color_pair(7) | curses.A_BOLD) + except curses.error: + pass + + if not info: + return + + title = f"{info['artists']} - {info['name']}" + album = info['album'] + progress = info['progress_ms'] + duration = info['duration_ms'] + + prog_bar = self._make_progress_bar(progress, duration, w - 4) + line1 = f" {title}" + line2 = f" {album} [{prog_bar}]" + + try: + self.stdscr.addnstr(y + 1, x, line1[:w-1], w-1, curses.color_pair(4)) + except curses.error: + pass + try: + self.stdscr.addnstr(y + 2, x, line2[:w-1], w-1, curses.color_pair(1)) + except curses.error: + pass + + def _draw_bar(self, y, x, w, h): + keys = "j/k:Move Enter:Select Space:Play/Pause n/p:Next/Prev +/-:Vol q:Quit" + if self.search_mode: + keys = f"Search: {self.search_query}_ Enter:Search Esc:Cancel" + try: + self.stdscr.addnstr(y, x, keys.ljust(w), w-1, curses.color_pair(7)) + except curses.error: + pass + + def _make_progress_bar(self, current, total, width): + if total == 0: + return "" + ratio = current / total + filled = int(width * ratio) + bar = "=" * filled + ">" + "-" * (width - filled - 1) + cur = self._format_ms(current) + dur = self._format_ms(total) + return f"{cur} [{bar}] {dur}" + + def _format_ms(self, ms): + s = int(ms / 1000) + m = s // 60 + s = s % 60 + return f"{m}:{s:02d}" + + def _get_view_header(self): + headers = { + View.HOME: "Home", + View.PLAYLISTS: "Playlists", + View.PLAYLIST_DETAIL: "Playlist", + View.LIKED_SONGS: "Liked Songs", + View.RECENT: "Recently Played", + View.LIBRARY: "Library", + View.QUEUE: "Queue", + View.DEVICES: "Devices", + View.SEARCH: "Search", + View.ALBUM_DETAIL: "Album", + View.ARTIST_DETAIL: "Artist", + View.ARTIST_TOP: "Top Tracks", + View.ALBUMS: "Albums", + View.ARTISTS: "Artists", + } + return headers.get(self.current_view, "") + + def _get_empty_message(self): + msgs = { + View.PLAYLISTS: "Keine Playlists gefunden.", + View.LIKED_SONGS: "Keine Liked Songs.", + View.RECENT: "Keine kuerzlich gespielten Tracks.", + View.QUEUE: "Warteschlange ist leer.", + View.DEVICES: "Keine Geraete gefunden.", + View.SEARCH: "Druecke '/' zum Suchen.", + View.LIBRARY: "Keine Eintraege.", + } + return msgs.get(self.current_view, "Keine Eintraege.") + + def _format_item(self, item): + kind = item.get("type", "") + if kind == "track": + artists = item.get("artists", "Unknown") + name = item.get("name", "Unknown") + album = item.get("album", "") + dur = item.get("duration", "") + return f"{artists} - {name} ({album}) {dur}" + elif kind == "playlist": + name = item.get("name", "Unknown") + tracks = item.get("tracks", "") + owner = item.get("owner", "") + return f"{name} ({tracks} tracks, by {owner})" + elif kind == "album": + name = item.get("name", "Unknown") + artist = item.get("artist", "") + year = item.get("year", "") + return f"{artist} - {name} ({year})" + elif kind == "artist": + name = item.get("name", "Unknown") + followers = item.get("followers", "") + return f"{name} ({followers} followers)" + elif kind == "device": + name = item.get("name", "Unknown") + dtype = item.get("dtype", "") + active = item.get("active", False) + marker = " *" if active else "" + return f"{name} ({dtype}){marker}" + elif kind == "search_track": + artists = item.get("artists", "Unknown") + name = item.get("name", "Unknown") + return f"[Track] {artists} - {name}" + elif kind == "search_album": + name = item.get("name", "Unknown") + artist = item.get("artist", "") + return f"[Album] {artist} - {name}" + elif kind == "search_artist": + name = item.get("name", "Unknown") + return f"[Artist] {name}" + elif kind == "search_playlist": + name = item.get("name", "Unknown") + return f"[Playlist] {name}" + return str(item) + + # --- Data Loading --- + def load_home(self): + self.loading = True + self.draw() + try: + recent = self.client.get_recently_played(limit=10) + items = [] + seen = set() + for r in recent.get("items", []): + track = r.get("track", {}) + if track and track.get("id") not in seen: + seen.add(track["id"]) + items.append({ + "type": "track", + "id": track["id"], + "name": track["name"], + "artists": ", ".join(a["name"] for a in track.get("artists", [])), + "album": track.get("album", {}).get("name", ""), + "uri": track["uri"], + "duration": self._format_ms(track.get("duration_ms", 0)), + "context_uri": track.get("album", {}).get("uri", ""), + }) + self.content_items = items + self.recent_data = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_playlists(self): + self.loading = True + self.draw() + try: + data = self.client.get_playlists(limit=50) + items = [] + for p in data.get("items", []): + items.append({ + "type": "playlist", + "id": p["id"], + "name": p["name"], + "tracks": p.get("tracks", {}).get("total", 0), + "owner": p.get("owner", {}).get("display_name", ""), + "uri": p["uri"], + }) + self.content_items = items + self.playlists_cache = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_playlist_detail(self, playlist_id): + self.loading = True + self.draw() + try: + data = self.client.get_playlist_tracks(playlist_id, limit=100) + items = [] + for t in data.get("items", []): + track = t.get("track", {}) + if not track: + continue + items.append({ + "type": "track", + "id": track["id"], + "name": track["name"], + "artists": ", ".join(a["name"] for a in track.get("artists", [])), + "album": track.get("album", {}).get("name", ""), + "uri": track["uri"], + "duration": self._format_ms(track.get("duration_ms", 0)), + "context_uri": f"spotify:playlist:{playlist_id}", + }) + self.content_items = items + self.playlist_tracks = items + self.context_uri = f"spotify:playlist:{playlist_id}" + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_liked_songs(self): + self.loading = True + self.draw() + try: + data = self.client.get_saved_tracks(limit=50, offset=self.liked_offset) + items = [] + for t in data.get("items", []): + track = t.get("track", {}) + if not track: + continue + items.append({ + "type": "track", + "id": track["id"], + "name": track["name"], + "artists": ", ".join(a["name"] for a in track.get("artists", [])), + "album": track.get("album", {}).get("name", ""), + "uri": track["uri"], + "duration": self._format_ms(track.get("duration_ms", 0)), + "context_uri": "spotify:library:tracks", + }) + self.content_items = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_recent(self): + self.loading = True + self.draw() + try: + data = self.client.get_recently_played(limit=50) + items = [] + seen = set() + for r in data.get("items", []): + track = r.get("track", {}) + if track and track.get("id") not in seen: + seen.add(track["id"]) + items.append({ + "type": "track", + "id": track["id"], + "name": track["name"], + "artists": ", ".join(a["name"] for a in track.get("artists", [])), + "album": track.get("album", {}).get("name", ""), + "uri": track["uri"], + "duration": self._format_ms(track.get("duration_ms", 0)), + "context_uri": track.get("album", {}).get("uri", ""), + }) + self.content_items = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_library(self): + self.loading = True + self.draw() + items = [ + {"type": "nav", "label": "Liked Songs", "action": "liked"}, + {"type": "nav", "label": "Albums", "action": "albums"}, + {"type": "nav", "label": "Artists", "action": "artists"}, + ] + self.content_items = items + self.loading = False + + def load_albums(self): + self.loading = True + self.draw() + try: + data = self.client.get_saved_albums(limit=50) + items = [] + for a in data.get("items", []): + album = a.get("album", {}) + items.append({ + "type": "album", + "id": album["id"], + "name": album["name"], + "artist": ", ".join(ar["name"] for ar in album.get("artists", [])), + "year": album.get("release_year", ""), + "uri": album["uri"], + "total_tracks": album.get("total_tracks", 0), + }) + self.content_items = items + self.albums_cache = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_artists(self): + self.loading = True + self.draw() + try: + data = self.client.get_saved_artists(limit=50) + items = [] + for a in data.get("items", []): + items.append({ + "type": "artist", + "id": a["id"], + "name": a["name"], + "followers": a.get("followers", {}).get("total", 0), + "uri": a["uri"], + }) + self.content_items = items + self.artists_cache = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_queue(self): + self.loading = True + self.draw() + try: + data = self.client.get_queue() + items = [] + current = data.get("currently_playing") + if current: + items.append({ + "type": "track", + "id": current["id"], + "name": f">>> {current['name']}", + "artists": ", ".join(a["name"] for a in current.get("artists", [])), + "album": current.get("album", {}).get("name", ""), + "uri": current["uri"], + "duration": self._format_ms(current.get("duration_ms", 0)), + }) + for t in data.get("queue", []): + items.append({ + "type": "track", + "id": t["id"], + "name": t["name"], + "artists": ", ".join(a["name"] for a in t.get("artists", [])), + "album": t.get("album", {}).get("name", ""), + "uri": t["uri"], + "duration": self._format_ms(t.get("duration_ms", 0)), + }) + self.content_items = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_devices(self): + self.loading = True + self.draw() + try: + self.player.refresh() + items = [] + for d in self.player.devices: + items.append({ + "type": "device", + "id": d["id"], + "name": d["name"], + "dtype": d.get("type", ""), + "active": d.get("is_active", False), + }) + self.content_items = items + self.devices_cache = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_album_detail(self, album_id): + self.loading = True + self.draw() + try: + album = self.client.get_album(album_id) + tracks = self.client.get_album_tracks(album_id, limit=50) + items = [] + for t in tracks.get("items", []): + items.append({ + "type": "track", + "id": t["id"], + "name": t["name"], + "artists": ", ".join(a["name"] for a in t.get("artists", [])), + "album": album.get("name", ""), + "uri": t["uri"], + "duration": self._format_ms(t.get("duration_ms", 0)), + "context_uri": f"spotify:album:{album_id}", + }) + self.content_items = items + self.album_tracks = items + self.context_uri = f"spotify:album:{album_id}" + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def load_artist_detail(self, artist_id): + self.loading = True + self.draw() + try: + artist = self.client.get_artist(artist_id) + self.artist_data = artist + + top = self.client.get_artist_top_tracks(artist_id) + self.artist_top_tracks = [] + for t in top.get("tracks", []): + self.artist_top_tracks.append({ + "type": "track", + "id": t["id"], + "name": t["name"], + "artists": ", ".join(a["name"] for a in t.get("artists", [])), + "album": t.get("album", {}).get("name", ""), + "uri": t["uri"], + "duration": self._format_ms(t.get("duration_ms", 0)), + "context_uri": f"spotify:artist:{artist_id}", + }) + + albums = self.client.get_artist_albums(artist_id, limit=20) + self.artist_albums = [] + for a in albums.get("items", []): + self.artist_albums.append({ + "type": "album", + "id": a["id"], + "name": a["name"], + "artist": artist["name"], + "year": a.get("release_date", "")[:4], + "uri": a["uri"], + "total_tracks": a.get("total_tracks", 0), + }) + + items = [{"type": "section", "label": f"--- {artist['name']} Top Tracks ---"}] + items.extend(self.artist_top_tracks) + items.append({"type": "section", "label": "--- Albums ---"}) + items.extend(self.artist_albums) + self.content_items = items + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + def do_search(self, query): + if not query: + return + self.loading = True + self.draw() + try: + data = self.client.search(query, types="track,album,artist,playlist", limit=10) + items = [] + for t in data.get("tracks", {}).get("items", []): + items.append({ + "type": "search_track", + "id": t["id"], + "name": t["name"], + "artists": ", ".join(a["name"] for a in t.get("artists", [])), + "album": t.get("album", {}).get("name", ""), + "uri": t["uri"], + "duration": self._format_ms(t.get("duration_ms", 0)), + "context_uri": t.get("album", {}).get("uri", ""), + }) + for a in data.get("albums", {}).get("items", []): + items.append({ + "type": "search_album", + "id": a["id"], + "name": a["name"], + "artist": ", ".join(ar["name"] for ar in a.get("artists", [])), + "year": a.get("release_date", "")[:4], + "uri": a["uri"], + }) + for ar in data.get("artists", {}).get("items", []): + items.append({ + "type": "search_artist", + "id": ar["id"], + "name": ar["name"], + "uri": ar["uri"], + }) + for p in data.get("playlists", {}).get("items", []): + items.append({ + "type": "search_playlist", + "id": p["id"], + "name": p["name"], + "uri": p["uri"], + }) + self.content_items = items + self.search_results = data + except Exception as e: + self.content_items = [] + self.show_message(f"Fehler: {e}") + self.loading = False + + # --- Navigation --- + def navigate_to(self, view): + self.current_view = view + self.content_scroll = 0 + self.selected_index = 0 + self.content_items = [] + + loaders = { + View.HOME: self.load_home, + View.PLAYLISTS: self.load_playlists, + View.LIKED_SONGS: self.load_liked_songs, + View.RECENT: self.load_recent, + View.LIBRARY: self.load_library, + View.QUEUE: self.load_queue, + View.DEVICES: self.load_devices, + View.ALBUMS: self.load_albums, + View.ARTISTS: self.load_artists, + } + if view in loaders: + loaders[view]() + + def handle_select(self): + if not self.content_items: + return + item = self.content_items[self.selected_index] + kind = item.get("type", "") + + if kind == "playlist": + self.current_view = View.PLAYLIST_DETAIL + self.load_playlist_detail(item["id"]) + elif kind == "album" or kind == "search_album": + self.current_view = View.ALBUM_DETAIL + self.load_album_detail(item["id"]) + elif kind == "artist" or kind == "search_artist": + self.current_view = View.ARTIST_DETAIL + self.load_artist_detail(item["id"]) + elif kind == "track" or kind == "search_track": + ctx = item.get("context_uri") + if self.context_uri: + ctx = self.context_uri + try: + self.player.play_tracks([item["uri"]]) + self.show_message(f"Spiele: {item['name']}") + except Exception as e: + self.show_message(f"Fehler: {e}") + elif kind == "device": + try: + self.player.transfer_to(item["id"]) + self.show_message(f"Übertragen zu: {item['name']}") + except Exception as e: + self.show_message(f"Fehler: {e}") + elif kind == "nav": + action = item.get("action", "") + if action == "liked": + self.navigate_to(View.LIKED_SONGS) + elif action == "albums": + self.navigate_to(View.ALBUMS) + elif action == "artists": + self.navigate_to(View.ARTISTS) + + def handle_back(self): + if 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) + else: + self.navigate_to(View.HOME) + + def run(self): + self.navigate_to(View.HOME) + + while self.running: + self.draw() + key = self.stdscr.getch() + + if key == -1: + continue + + if self.search_mode: + self._handle_search_input(key) + continue + + self._handle_key(key) + + def _handle_search_input(self, key): + if key == 27: + self.search_mode = False + self.search_query = "" + return + elif key in (curses.KEY_ENTER, 10, 13): + self.search_mode = False + self.current_view = View.SEARCH + self.do_search(self.search_query) + return + elif key in (curses.KEY_BACKSPACE, 127, 8): + self.search_query = self.search_query[:-1] + elif 32 <= key <= 126: + self.search_query += chr(key) + + def _handle_key(self, key): + nav_count = len(self.NAV_ITEMS) + content_count = len(self.content_items) + + if key == ord("q"): + self.running = False + + elif key == ord("j") or key == curses.KEY_DOWN: + if self.selected_index < content_count - 1: + self.selected_index += 1 + self._adjust_scroll() + + elif key == ord("k") or key == curses.KEY_UP: + if self.selected_index > 0: + self.selected_index -= 1 + self._adjust_scroll() + + elif key == curses.KEY_PPAGE: + self.selected_index = max(0, self.selected_index - 10) + self._adjust_scroll() + + elif key == curses.KEY_NPAGE: + self.selected_index = min(content_count - 1, self.selected_index + 10) + self._adjust_scroll() + + elif key == curses.KEY_HOME: + self.selected_index = 0 + self.content_scroll = 0 + + elif key == curses.KEY_END: + self.selected_index = max(0, content_count - 1) + self._adjust_scroll() + + elif key == 10 or key == 13: + self.handle_select() + + elif key == ord(" "): + try: + self.player.toggle_play() + self.show_message("Toggled play/pause") + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("n"): + try: + self.player.next() + self.show_message("Next track") + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("p"): + try: + self.player.previous() + self.show_message("Previous track") + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("+") or key == ord("="): + try: + self.player.volume_up() + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("-"): + try: + self.player.volume_down() + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("r"): + try: + self.player.toggle_repeat() + self.show_message("Toggle repeat") + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("s"): + try: + self.player.toggle_shuffle() + self.show_message("Toggle shuffle") + except Exception as e: + self.show_message(f"Fehler: {e}") + + elif key == ord("/"): + self.search_mode = True + self.search_query = "" + + elif key == ord("a"): + if self.current_view == View.PLAYLISTS and self.content_items: + self._add_to_playlist_prompt() + + elif key == ord("h") or key == 27: + self.handle_back() + + elif key == ord("1"): + self.nav_index = 0 + self.navigate_to(self.NAV_ITEMS[0][0]) + elif key == ord("2"): + self.nav_index = 1 + self.navigate_to(self.NAV_ITEMS[1][0]) + elif key == ord("3"): + self.nav_index = 2 + self.navigate_to(self.NAV_ITEMS[2][0]) + elif key == ord("4"): + self.nav_index = 3 + self.navigate_to(self.NAV_ITEMS[3][0]) + elif key == ord("5"): + self.nav_index = 4 + self.navigate_to(self.NAV_ITEMS[4][0]) + elif key == ord("6"): + self.nav_index = 5 + self.navigate_to(self.NAV_ITEMS[5][0]) + elif key == ord("7"): + self.nav_index = 6 + self.navigate_to(self.NAV_ITEMS[6][0]) + elif key == ord("8"): + self.nav_index = 7 + self.navigate_to(self.NAV_ITEMS[7][0]) + + elif key == ord("L"): + self.navigate_to(View.LIBRARY) + + def _adjust_scroll(self): + h, w = self.stdscr.getmaxyx() + visible = h - 7 + if self.selected_index < self.content_scroll: + self.content_scroll = self.selected_index + elif self.selected_index >= self.content_scroll + visible: + self.content_scroll = self.selected_index - visible + 1 + + def _add_to_playlist_prompt(self): + pass