feat: terminal spotify client (ncmpcpp-style)

This commit is contained in:
x3
2026-09-06 08:08:56 +00:00
commit 352ed02871
12 changed files with 1673 additions and 0 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
from .main import main
main()
+168
View File
@@ -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"<h1>Authentifizierung erfolgreich! Du kannst das Fenster schliessen.</h1>")
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.")
+201
View File
@@ -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
+46
View File
@@ -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()
+44
View File
@@ -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()
+166
View File
@@ -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", ""),
}
+924
View File
@@ -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