From d9aa8303a7f454caaff6e176af2d5efe4ac50aec Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:49:00 -0800 Subject: [PATCH] Add SoulSync REST API (v1) with API key authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full public REST API at /api/v1/ with 32 endpoints covering library, search, downloads, wishlist, watchlist, playlists, system status, and settings. Includes API key authentication (Bearer token), per-endpoint rate limiting, and consistent JSON response format. API keys can be generated and managed from the Settings page. No changes to existing functionality — the API delegates to the same backend services the web UI uses. --- Support/API.md | 443 +++++++++++++++++++++++++++++++++++++++++ api/__init__.py | 72 +++++++ api/auth.py | 76 +++++++ api/downloads.py | 81 ++++++++ api/helpers.py | 51 +++++ api/library.py | 158 +++++++++++++++ api/playlists.py | 152 ++++++++++++++ api/search.py | 170 ++++++++++++++++ api/settings.py | 181 +++++++++++++++++ api/system.py | 101 ++++++++++ api/watchlist.py | 92 +++++++++ api/wishlist.py | 108 ++++++++++ requirements-webui.txt | 1 + requirements.txt | 1 + web_server.py | 70 +++++++ webui/index.html | 37 ++++ webui/static/script.js | 113 +++++++++++ 17 files changed, 1907 insertions(+) create mode 100644 Support/API.md create mode 100644 api/__init__.py create mode 100644 api/auth.py create mode 100644 api/downloads.py create mode 100644 api/helpers.py create mode 100644 api/library.py create mode 100644 api/playlists.py create mode 100644 api/search.py create mode 100644 api/settings.py create mode 100644 api/system.py create mode 100644 api/watchlist.py create mode 100644 api/wishlist.py diff --git a/Support/API.md b/Support/API.md new file mode 100644 index 00000000..f893dc78 --- /dev/null +++ b/Support/API.md @@ -0,0 +1,443 @@ +# SoulSync REST API + +SoulSync includes a full REST API at `/api/v1/` that lets you control everything from external apps, scripts, Discord bots, Home Assistant, or anything that can make HTTP requests. + +## Quick Start + +### 1. Generate an API Key + +Go to **Settings** in the SoulSync web UI and find the **SoulSync API** section. Click **Generate API Key**, give it a label, and copy the key immediately — it's only shown once. + +Alternatively, if no keys exist yet, use the bootstrap endpoint: + +```bash +curl -X POST http://localhost:8008/api/v1/api-keys/bootstrap \ + -H "Content-Type: application/json" \ + -d '{"label": "My First Key"}' +``` + +### 2. Make Requests + +Pass your key via the `Authorization` header: + +```bash +curl -H "Authorization: Bearer sk_your_key_here" \ + http://localhost:8008/api/v1/system/status +``` + +Or as a query parameter: + +``` +http://localhost:8008/api/v1/system/status?api_key=sk_your_key_here +``` + +### 3. Response Format + +Every response follows this format: + +```json +{ + "success": true, + "data": { ... }, + "error": null, + "pagination": null +} +``` + +Errors: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "NOT_FOUND", + "message": "Artist 999 not found." + } +} +``` + +--- + +## Authentication + +All `/api/v1/` endpoints require an API key (except the bootstrap endpoint). + +| Method | Details | +|--------|---------| +| Header | `Authorization: Bearer sk_...` | +| Query | `?api_key=sk_...` | + +Keys are generated as `sk_` followed by a random token. Only the SHA-256 hash is stored — the raw key is shown once at creation. + +### Error Codes + +| Status | Code | Meaning | +|--------|------|---------| +| 401 | `AUTH_REQUIRED` | No API key provided | +| 403 | `INVALID_KEY` | API key is wrong or revoked | + +--- + +## Rate Limiting + +Requests are rate-limited per IP address: + +| Endpoint Type | Limit | +|---------------|-------| +| Read (GET) | 60/min | +| Search (POST /search/*) | 20/min | +| Write (POST/DELETE/PATCH) | 30/min | +| Downloads (POST /downloads) | 10/min | +| System polling (GET /system/*) | 120/min | + +Exceeding the limit returns `429 RATE_LIMITED`. + +--- + +## Endpoints + +### System + +#### `GET /api/v1/system/status` + +Server status, uptime, and service connectivity. + +```json +{ + "data": { + "uptime": "2h 15m 30s", + "uptime_seconds": 8130, + "services": { + "spotify": true, + "soulseek": true, + "hydrabase": false + } + } +} +``` + +#### `GET /api/v1/system/activity` + +Recent activity feed. + +#### `GET /api/v1/system/stats` + +Combined library and download statistics. + +--- + +### Library + +#### `GET /api/v1/library/artists` + +List library artists with search and pagination. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `search` | string | | Filter by name | +| `letter` | string | `all` | Filter by first letter (a-z, #) | +| `page` | int | 1 | Page number | +| `limit` | int | 50 | Items per page (max 200) | +| `watchlist` | string | `all` | `all`, `watched`, or `unwatched` | + +#### `GET /api/v1/library/artists/` + +Get artist details with album list. + +#### `GET /api/v1/library/artists//albums` + +List albums for an artist. + +#### `GET /api/v1/library/albums//tracks` + +List tracks in an album. + +#### `GET /api/v1/library/tracks` + +Search tracks by title and/or artist. + +| Param | Type | Description | +|-------|------|-------------| +| `title` | string | Track title to search | +| `artist` | string | Artist name to search | +| `limit` | int | Max results (default 50, max 200) | + +#### `GET /api/v1/library/stats` + +Library statistics (artist/album/track counts, database info). + +--- + +### Search + +Search external music sources (Spotify, iTunes, Hydrabase). + +#### `POST /api/v1/search/tracks` + +```json +{ + "query": "Daft Punk Around the World", + "source": "auto", + "limit": 20 +} +``` + +`source`: `"auto"` (default — tries Hydrabase, then Spotify, then iTunes), `"spotify"`, or `"itunes"`. + +#### `POST /api/v1/search/albums` + +```json +{ + "query": "Discovery", + "limit": 10 +} +``` + +#### `POST /api/v1/search/artists` + +```json +{ + "query": "Daft Punk", + "limit": 10 +} +``` + +--- + +### Downloads + +#### `GET /api/v1/downloads` + +List active and recent download tasks. + +#### `POST /api/v1/downloads//cancel` + +Cancel a specific download. + +```json +{ + "username": "soulseek_username" +} +``` + +#### `POST /api/v1/downloads/cancel-all` + +Cancel all active downloads and clear completed ones. + +--- + +### Wishlist + +Tracks that failed to download, queued for retry. + +#### `GET /api/v1/wishlist` + +List wishlist tracks. + +| Param | Type | Description | +|-------|------|-------------| +| `category` | string | `singles` or `albums` | +| `page` | int | Page number | +| `limit` | int | Items per page | + +#### `POST /api/v1/wishlist` + +Add a track to the wishlist. + +```json +{ + "spotify_track_data": { "id": "...", "name": "...", "artists": [...] }, + "failure_reason": "No sources found", + "source_type": "api" +} +``` + +#### `DELETE /api/v1/wishlist/` + +Remove a track from the wishlist. + +#### `POST /api/v1/wishlist/process` + +Trigger wishlist download processing. + +--- + +### Watchlist + +Artists being monitored for new releases. + +#### `GET /api/v1/watchlist` + +List all watched artists. + +#### `POST /api/v1/watchlist` + +Add an artist to the watchlist. + +```json +{ + "artist_id": "4tZwfgrHOc3mvqYlEYSvnL", + "artist_name": "Daft Punk" +} +``` + +#### `DELETE /api/v1/watchlist/` + +Remove an artist from the watchlist. + +#### `POST /api/v1/watchlist/scan` + +Trigger a watchlist scan for new releases. + +--- + +### Playlists + +#### `GET /api/v1/playlists` + +List user playlists. + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `source` | string | `spotify` | `spotify` or `tidal` | + +#### `GET /api/v1/playlists/` + +Get playlist details with tracks. + +#### `POST /api/v1/playlists//sync` + +Trigger playlist sync/download. + +```json +{ + "playlist_name": "My Playlist", + "tracks": [...] +} +``` + +--- + +### Settings + +#### `GET /api/v1/settings` + +Get current settings (sensitive values like passwords and tokens are redacted). + +#### `PATCH /api/v1/settings` + +Update settings (partial update). + +```json +{ + "soulseek.search_timeout": 90, + "logging.level": "DEBUG" +} +``` + +--- + +### API Key Management + +#### `GET /api/v1/api-keys` + +List all API keys (shows prefix and label only, never the full key). + +#### `POST /api/v1/api-keys` + +Generate a new API key. + +```json +{ + "label": "Discord Bot" +} +``` + +Response includes the raw key (shown only once): + +```json +{ + "data": { + "key": "sk_a3Bf9x2Kp7...", + "id": "uuid", + "label": "Discord Bot", + "key_prefix": "sk_a3Bf9x2", + "created_at": "2026-03-03T12:00:00Z" + } +} +``` + +#### `DELETE /api/v1/api-keys/` + +Revoke an API key. + +#### `POST /api/v1/api-keys/bootstrap` + +Generate the first API key when none exist (no auth required). Returns 403 if keys already exist. + +--- + +## Examples + +### Python + +```python +import requests + +API_URL = "http://localhost:8008/api/v1" +API_KEY = "sk_your_key_here" + +headers = {"Authorization": f"Bearer {API_KEY}"} + +# Search for tracks +resp = requests.post(f"{API_URL}/search/tracks", + headers=headers, + json={"query": "Daft Punk", "limit": 5}) +tracks = resp.json()["data"]["tracks"] + +# Add artist to watchlist +requests.post(f"{API_URL}/watchlist", + headers=headers, + json={"artist_id": "4tZwfgrHOc3mvqYlEYSvnL", "artist_name": "Daft Punk"}) + +# Check system status +status = requests.get(f"{API_URL}/system/status", headers=headers).json() +print(f"Uptime: {status['data']['uptime']}") +``` + +### JavaScript + +```javascript +const API_URL = 'http://localhost:8008/api/v1'; +const API_KEY = 'sk_your_key_here'; + +const headers = { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json' +}; + +// Browse library +const artists = await fetch(`${API_URL}/library/artists?page=1&limit=25`, { headers }) + .then(r => r.json()); + +// Trigger watchlist scan +await fetch(`${API_URL}/watchlist/scan`, { method: 'POST', headers }); +``` + +### curl + +```bash +# System status +curl -H "Authorization: Bearer sk_..." http://localhost:8008/api/v1/system/status + +# Search tracks +curl -X POST http://localhost:8008/api/v1/search/tracks \ + -H "Authorization: Bearer sk_..." \ + -H "Content-Type: application/json" \ + -d '{"query": "Boards of Canada", "limit": 5}' + +# Library artists page 1 +curl -H "Authorization: Bearer sk_..." \ + "http://localhost:8008/api/v1/library/artists?page=1&limit=25" +``` diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..714084b3 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,72 @@ +""" +SoulSync Public REST API (v1) + +Blueprint factory + rate-limiter initialisation. +""" + +from flask import Blueprint +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +from utils.logging_config import get_logger +from .helpers import api_error + +logger = get_logger("api_v1") + +# --------------------------------------------------------------------------- +# Rate limiter (initialised with the app in web_server.py via limiter.init_app) +# --------------------------------------------------------------------------- +limiter = Limiter( + key_func=get_remote_address, + default_limits=["60 per minute"], + storage_uri="memory://", +) + + +def create_api_blueprint(): + """Build and return the /api/v1 Blueprint with all sub-modules registered.""" + + bp = Blueprint("api_v1", __name__) + + # ---- import & register sub-module routes ---- + from .library import register_routes as reg_library + from .system import register_routes as reg_system + from .search import register_routes as reg_search + from .wishlist import register_routes as reg_wishlist + from .watchlist import register_routes as reg_watchlist + from .downloads import register_routes as reg_downloads + from .playlists import register_routes as reg_playlists + from .settings import register_routes as reg_settings + + reg_library(bp) + reg_system(bp) + reg_search(bp) + reg_wishlist(bp) + reg_watchlist(bp) + reg_downloads(bp) + reg_playlists(bp) + reg_settings(bp) + + # ---- error handlers (scoped to this Blueprint) ---- + @bp.errorhandler(400) + def _bad_request(e): + return api_error("BAD_REQUEST", str(e), 400) + + @bp.errorhandler(404) + def _not_found(e): + return api_error("NOT_FOUND", "Resource not found.", 404) + + @bp.errorhandler(429) + def _rate_limited(e): + return api_error("RATE_LIMITED", "Too many requests. Please slow down.", 429) + + @bp.errorhandler(500) + def _internal(e): + return api_error("INTERNAL_ERROR", "An internal server error occurred.", 500) + + @bp.errorhandler(Exception) + def _unhandled(e): + logger.error(f"Unhandled API error: {e}", exc_info=True) + return api_error("INTERNAL_ERROR", "An unexpected error occurred.", 500) + + return bp diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000..3ddecc42 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,76 @@ +""" +API key authentication for the SoulSync public API. +""" + +import hashlib +import secrets +import uuid +from datetime import datetime, timezone +from functools import wraps + +from flask import request, current_app + +from .helpers import api_error + + +def generate_api_key(label=""): + """Generate a new API key. + + Returns (raw_key, key_record). The raw key is shown to the user + exactly once; only the SHA-256 hash is persisted. + """ + raw_key = f"sk_{secrets.token_urlsafe(32)}" + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + record = { + "id": str(uuid.uuid4()), + "label": label, + "key_hash": key_hash, + "key_prefix": raw_key[:11], # "sk_" + first 8 chars + "created_at": datetime.now(timezone.utc).isoformat(), + "last_used_at": None, + } + return raw_key, record + + +def _hash_key(raw_key): + return hashlib.sha256(raw_key.encode()).hexdigest() + + +def require_api_key(f): + """Decorator that enforces API key authentication.""" + + @wraps(f) + def decorated(*args, **kwargs): + # Extract key from header or query param + api_key = None + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + api_key = auth_header[7:] + if not api_key: + api_key = request.args.get("api_key") + + if not api_key: + return api_error("AUTH_REQUIRED", "API key is required. " + "Pass via Authorization: Bearer header " + "or ?api_key= query parameter.", 401) + + config_mgr = current_app.soulsync["config_manager"] + stored_keys = config_mgr.get("api_keys", []) + key_hash = _hash_key(api_key) + + matched = None + for stored in stored_keys: + if stored.get("key_hash") == key_hash: + matched = stored + break + + if not matched: + return api_error("INVALID_KEY", "Invalid API key.", 403) + + # Update last-used timestamp (best-effort) + matched["last_used_at"] = datetime.now(timezone.utc).isoformat() + config_mgr.set("api_keys", stored_keys) + + return f(*args, **kwargs) + + return decorated diff --git a/api/downloads.py b/api/downloads.py new file mode 100644 index 00000000..7854c119 --- /dev/null +++ b/api/downloads.py @@ -0,0 +1,81 @@ +""" +Download management endpoints — list, cancel active downloads. +""" + +from flask import request, current_app +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/downloads", methods=["GET"]) + @require_api_key + def list_downloads(): + """List active and recent download tasks.""" + try: + from web_server import download_tasks, tasks_lock + + tasks = [] + with tasks_lock: + for task_id, task in download_tasks.items(): + tasks.append({ + "id": task_id, + "status": task.get("status"), + "track_name": task.get("track_name"), + "artist_name": task.get("artist_name"), + "album_name": task.get("album_name"), + "username": task.get("username"), + "filename": task.get("filename"), + "progress": task.get("progress", 0), + "size": task.get("size"), + "error": task.get("error"), + }) + + return api_success({"downloads": tasks}) + except ImportError: + return api_error("NOT_AVAILABLE", "Download tracking not available.", 501) + except Exception as e: + return api_error("DOWNLOAD_ERROR", str(e), 500) + + @bp.route("/downloads//cancel", methods=["POST"]) + @require_api_key + def cancel_download(download_id): + """Cancel a specific download. + + Body: {"username": "..."} + """ + body = request.get_json(silent=True) or {} + username = body.get("username") + + if not username: + return api_error("BAD_REQUEST", "Missing 'username' in body.", 400) + + try: + from utils.async_helpers import run_async + soulseek = current_app.soulsync.get("soulseek_client") + if not soulseek: + return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) + + ok = run_async(soulseek.cancel_download(download_id, username, remove=True)) + if ok: + return api_success({"message": "Download cancelled."}) + return api_error("CANCEL_FAILED", "Failed to cancel download.", 500) + except Exception as e: + return api_error("DOWNLOAD_ERROR", str(e), 500) + + @bp.route("/downloads/cancel-all", methods=["POST"]) + @require_api_key + def cancel_all_downloads(): + """Cancel all active downloads and clear completed ones.""" + try: + from utils.async_helpers import run_async + soulseek = current_app.soulsync.get("soulseek_client") + if not soulseek: + return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503) + + run_async(soulseek.cancel_all_downloads()) + run_async(soulseek.clear_all_completed_downloads()) + return api_success({"message": "All downloads cancelled and cleared."}) + except Exception as e: + return api_error("DOWNLOAD_ERROR", str(e), 500) diff --git a/api/helpers.py b/api/helpers.py new file mode 100644 index 00000000..c41776aa --- /dev/null +++ b/api/helpers.py @@ -0,0 +1,51 @@ +""" +Shared response helpers for the SoulSync public API. +""" + +from flask import jsonify + + +def api_success(data, pagination=None, status=200): + """Wrap a successful response in the standard envelope.""" + return jsonify({ + "success": True, + "data": data, + "error": None, + "pagination": pagination, + }), status + + +def api_error(code, message, status=400): + """Wrap an error response in the standard envelope.""" + return jsonify({ + "success": False, + "data": None, + "error": {"code": code, "message": message}, + "pagination": None, + }), status + + +def build_pagination(page, limit, total): + """Build a pagination dict from page/limit/total.""" + total_pages = max(1, (total + limit - 1) // limit) + return { + "page": page, + "limit": limit, + "total": total, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + } + + +def parse_pagination(request, default_limit=50, max_limit=200): + """Extract and validate page/limit from a Flask request.""" + try: + page = max(1, int(request.args.get("page", 1))) + except (ValueError, TypeError): + page = 1 + try: + limit = min(max_limit, max(1, int(request.args.get("limit", default_limit)))) + except (ValueError, TypeError): + limit = default_limit + return page, limit diff --git a/api/library.py b/api/library.py new file mode 100644 index 00000000..94a0c0f8 --- /dev/null +++ b/api/library.py @@ -0,0 +1,158 @@ +""" +Library endpoints — browse artists, albums, tracks, and stats. +""" + +from flask import request, current_app +from database.music_database import get_database +from .auth import require_api_key +from .helpers import api_success, api_error, build_pagination, parse_pagination + + +def register_routes(bp): + + @bp.route("/library/artists", methods=["GET"]) + @require_api_key + def list_artists(): + """List library artists with optional search, letter filter, and pagination.""" + page, limit = parse_pagination(request) + search = request.args.get("search", "") + letter = request.args.get("letter", "all") + watchlist = request.args.get("watchlist", "all") + + try: + db = get_database() + result = db.get_library_artists( + search_query=search, + letter=letter, + page=page, + limit=limit, + watchlist_filter=watchlist, + ) + artists = result.get("artists", []) + pag = result.get("pagination", {}) + pagination = build_pagination( + page, limit, pag.get("total_count", len(artists)) + ) + return api_success({"artists": artists}, pagination=pagination) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + @bp.route("/library/artists/", methods=["GET"]) + @require_api_key + def get_artist(artist_id): + """Get a single artist by ID with album list.""" + try: + db = get_database() + artist = db.get_artist(int(artist_id)) + if not artist: + return api_error("NOT_FOUND", f"Artist {artist_id} not found.", 404) + + albums = db.get_albums_by_artist(int(artist_id)) + return api_success({ + "artist": _serialize_artist(artist), + "albums": [_serialize_album(a) for a in albums], + }) + except ValueError: + return api_error("BAD_REQUEST", "artist_id must be an integer.", 400) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + @bp.route("/library/artists//albums", methods=["GET"]) + @require_api_key + def get_artist_albums(artist_id): + """List albums for an artist.""" + try: + db = get_database() + albums = db.get_albums_by_artist(int(artist_id)) + return api_success({"albums": [_serialize_album(a) for a in albums]}) + except ValueError: + return api_error("BAD_REQUEST", "artist_id must be an integer.", 400) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + @bp.route("/library/albums//tracks", methods=["GET"]) + @require_api_key + def get_album_tracks(album_id): + """List tracks in an album.""" + try: + db = get_database() + tracks = db.get_tracks_by_album(int(album_id)) + return api_success({"tracks": [_serialize_track(t) for t in tracks]}) + except ValueError: + return api_error("BAD_REQUEST", "album_id must be an integer.", 400) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + @bp.route("/library/tracks", methods=["GET"]) + @require_api_key + def library_search_tracks(): + """Search tracks by title and/or artist.""" + title = request.args.get("title", "") + artist = request.args.get("artist", "") + limit = min(200, max(1, int(request.args.get("limit", 50)))) + + if not title and not artist: + return api_error("BAD_REQUEST", "Provide at least 'title' or 'artist' query param.", 400) + + try: + db = get_database() + tracks = db.search_tracks(title=title, artist=artist, limit=limit) + return api_success({"tracks": [_serialize_track(t) for t in tracks]}) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + @bp.route("/library/stats", methods=["GET"]) + @require_api_key + def library_stats(): + """Get library statistics (artist/album/track counts, DB info).""" + try: + db = get_database() + info = db.get_database_info_for_server() + stats = db.get_statistics_for_server() + return api_success({ + "artists": stats.get("artists", 0), + "albums": stats.get("albums", 0), + "tracks": stats.get("tracks", 0), + "database_size_mb": info.get("database_size_mb"), + "last_update": info.get("last_update"), + }) + except Exception as e: + return api_error("LIBRARY_ERROR", str(e), 500) + + +# ---- serialization helpers ---- + +def _serialize_artist(a): + return { + "id": a.id, + "name": a.name, + "thumb_url": a.thumb_url, + "genres": a.genres or [], + "summary": a.summary, + } + + +def _serialize_album(a): + return { + "id": a.id, + "artist_id": a.artist_id, + "title": a.title, + "year": a.year, + "thumb_url": a.thumb_url, + "genres": a.genres or [], + "track_count": a.track_count, + "duration": a.duration, + } + + +def _serialize_track(t): + return { + "id": t.id, + "album_id": t.album_id, + "artist_id": t.artist_id, + "title": t.title, + "track_number": t.track_number, + "duration": t.duration, + "file_path": t.file_path, + "bitrate": t.bitrate, + } diff --git a/api/playlists.py b/api/playlists.py new file mode 100644 index 00000000..75754e85 --- /dev/null +++ b/api/playlists.py @@ -0,0 +1,152 @@ +""" +Playlist endpoints — list and inspect playlists from Spotify/Tidal. +""" + +from flask import request, current_app +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/playlists", methods=["GET"]) + @require_api_key + def list_playlists(): + """List user playlists from Spotify or Tidal. + + Query: ?source=spotify|tidal (default: spotify) + """ + source = request.args.get("source", "spotify") + ctx = current_app.soulsync + + try: + if source == "spotify": + spotify = ctx.get("spotify_client") + if not spotify or not spotify.is_authenticated(): + return api_error("NOT_AUTHENTICATED", "Spotify not authenticated.", 401) + + playlists = spotify.get_user_playlists_metadata_only() + return api_success({ + "playlists": [ + { + "id": p.id, + "name": p.name, + "owner": p.owner, + "track_count": p.total_tracks, + "image_url": getattr(p, "image_url", None), + } + for p in playlists + ], + "source": "spotify", + }) + + elif source == "tidal": + tidal = ctx.get("tidal_client") + if not tidal: + return api_error("NOT_AVAILABLE", "Tidal client not configured.", 503) + + playlists = tidal.get_user_playlists_metadata_only() + return api_success({ + "playlists": [ + { + "id": p.get("id") or p.get("uuid"), + "name": p.get("title") or p.get("name"), + "track_count": p.get("numberOfTracks", 0), + "image_url": p.get("image"), + } + for p in (playlists or []) + ], + "source": "tidal", + }) + + return api_error("BAD_REQUEST", "source must be 'spotify' or 'tidal'.", 400) + except Exception as e: + return api_error("PLAYLIST_ERROR", str(e), 500) + + @bp.route("/playlists/", methods=["GET"]) + @require_api_key + def get_playlist(playlist_id): + """Get playlist details with tracks. + + Query: ?source=spotify (default: spotify) + """ + source = request.args.get("source", "spotify") + ctx = current_app.soulsync + + try: + if source == "spotify": + spotify = ctx.get("spotify_client") + if not spotify or not spotify.is_authenticated(): + return api_error("NOT_AUTHENTICATED", "Spotify not authenticated.", 401) + + playlist = spotify.get_playlist_by_id(playlist_id) + if not playlist: + return api_error("NOT_FOUND", "Playlist not found.", 404) + + tracks = [] + for item in playlist.get("tracks", {}).get("items", []): + t = item.get("track") + if not t: + continue + tracks.append({ + "id": t.get("id"), + "name": t.get("name"), + "artists": [a.get("name") for a in t.get("artists", [])], + "album": t.get("album", {}).get("name"), + "duration_ms": t.get("duration_ms"), + "image_url": (t.get("album", {}).get("images", [{}])[0].get("url") + if t.get("album", {}).get("images") else None), + }) + + return api_success({ + "playlist": { + "id": playlist.get("id"), + "name": playlist.get("name"), + "owner": playlist.get("owner", {}).get("display_name"), + "total_tracks": playlist.get("tracks", {}).get("total", len(tracks)), + "tracks": tracks, + }, + "source": "spotify", + }) + + return api_error("BAD_REQUEST", "source must be 'spotify'.", 400) + except Exception as e: + return api_error("PLAYLIST_ERROR", str(e), 500) + + @bp.route("/playlists//sync", methods=["POST"]) + @require_api_key + def sync_playlist(playlist_id): + """Trigger playlist sync/download. + + This delegates to the internal sync endpoint by forwarding the request. + Body: {"playlist_name": "...", "tracks": [...]} + """ + body = request.get_json(silent=True) or {} + playlist_name = body.get("playlist_name") + tracks = body.get("tracks") + + if not playlist_name or not tracks: + return api_error("BAD_REQUEST", "Missing 'playlist_name' or 'tracks' in body.", 400) + + try: + from web_server import sync_states + if playlist_id in sync_states and sync_states[playlist_id].get("phase") not in ("complete", "error", None): + return api_error("CONFLICT", "Sync already in progress for this playlist.", 409) + except ImportError: + pass + + try: + # Forward to the internal sync endpoint + import requests as http_requests + internal_url = f"http://127.0.0.1:8008/api/sync/start" + resp = http_requests.post(internal_url, json={ + "playlist_id": playlist_id, + "playlist_name": playlist_name, + "tracks": tracks, + }, timeout=10) + data = resp.json() + if data.get("success"): + return api_success({"message": "Playlist sync started.", "playlist_id": playlist_id}) + return api_error("SYNC_FAILED", data.get("error", "Sync failed to start."), 500) + except Exception as e: + return api_error("PLAYLIST_ERROR", str(e), 500) diff --git a/api/search.py b/api/search.py new file mode 100644 index 00000000..e773da1c --- /dev/null +++ b/api/search.py @@ -0,0 +1,170 @@ +""" +Search endpoints — search external sources (Spotify, iTunes, Hydrabase). +""" + +from flask import request, current_app +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/search/tracks", methods=["POST"]) + @require_api_key + def search_tracks(): + """Search for tracks across music sources. + + Body: {"query": "...", "source": "spotify"|"itunes"|"auto", "limit": 20} + """ + body = request.get_json(silent=True) or {} + query = body.get("query", "").strip() + source = body.get("source", "auto") + limit = min(50, max(1, int(body.get("limit", 20)))) + + if not query: + return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400) + + try: + ctx = current_app.soulsync + tracks = [] + + # Hydrabase first when active + hydrabase = ctx.get("hydrabase_client") + if source == "auto" and hydrabase: + try: + from web_server import _is_hydrabase_active + if _is_hydrabase_active(): + hydra_results = hydrabase.search_tracks(query, limit=limit) + if hydra_results: + tracks = [_serialize_track(t) for t in hydra_results] + return api_success({"tracks": tracks, "source": "hydrabase"}) + except Exception: + pass + + spotify = ctx.get("spotify_client") + if source in ("spotify", "auto") and spotify and spotify.is_authenticated(): + results = spotify.search_tracks(query, limit=limit) + if results: + tracks = [_serialize_track(t) for t in results] + return api_success({"tracks": tracks, "source": "spotify"}) + + if source in ("itunes", "auto"): + from core.itunes_client import iTunesClient + itunes = iTunesClient() + results = itunes.search_tracks(query, limit=limit) + if results: + tracks = [_serialize_track(t) for t in results] + return api_success({"tracks": tracks, "source": "itunes"}) + + return api_success({"tracks": [], "source": source}) + except Exception as e: + return api_error("SEARCH_ERROR", str(e), 500) + + @bp.route("/search/albums", methods=["POST"]) + @require_api_key + def search_albums(): + """Search for albums. + + Body: {"query": "...", "limit": 20} + """ + body = request.get_json(silent=True) or {} + query = body.get("query", "").strip() + limit = min(50, max(1, int(body.get("limit", 20)))) + + if not query: + return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400) + + try: + ctx = current_app.soulsync + spotify = ctx.get("spotify_client") + if spotify and spotify.is_authenticated(): + results = spotify.search_albums(query, limit=limit) + if results: + return api_success({ + "albums": [_serialize_album(a) for a in results], + "source": "spotify", + }) + + from core.itunes_client import iTunesClient + itunes = iTunesClient() + results = itunes.search_albums(query, limit=limit) + return api_success({ + "albums": [_serialize_album(a) for a in results] if results else [], + "source": "itunes", + }) + except Exception as e: + return api_error("SEARCH_ERROR", str(e), 500) + + @bp.route("/search/artists", methods=["POST"]) + @require_api_key + def search_artists(): + """Search for artists. + + Body: {"query": "...", "limit": 20} + """ + body = request.get_json(silent=True) or {} + query = body.get("query", "").strip() + limit = min(50, max(1, int(body.get("limit", 20)))) + + if not query: + return api_error("BAD_REQUEST", "Missing 'query' in request body.", 400) + + try: + ctx = current_app.soulsync + spotify = ctx.get("spotify_client") + if spotify and spotify.is_authenticated(): + results = spotify.search_artists(query, limit=limit) + if results: + return api_success({ + "artists": [_serialize_artist(a) for a in results], + "source": "spotify", + }) + + from core.itunes_client import iTunesClient + itunes = iTunesClient() + results = itunes.search_artists(query, limit=limit) + return api_success({ + "artists": [_serialize_artist(a) for a in results] if results else [], + "source": "itunes", + }) + except Exception as e: + return api_error("SEARCH_ERROR", str(e), 500) + + +# ---- serialization (from core dataclasses) ---- + +def _serialize_track(t): + return { + "id": t.id, + "name": t.name, + "artists": t.artists, + "album": t.album, + "duration_ms": t.duration_ms, + "popularity": t.popularity, + "preview_url": t.preview_url, + "image_url": t.image_url, + "release_date": t.release_date, + } + + +def _serialize_album(a): + return { + "id": a.id, + "name": a.name, + "artists": a.artists, + "release_date": a.release_date, + "total_tracks": a.total_tracks, + "album_type": a.album_type, + "image_url": a.image_url, + } + + +def _serialize_artist(a): + return { + "id": a.id, + "name": a.name, + "popularity": a.popularity, + "genres": a.genres, + "followers": a.followers, + "image_url": a.image_url, + } diff --git a/api/settings.py b/api/settings.py new file mode 100644 index 00000000..94d779bf --- /dev/null +++ b/api/settings.py @@ -0,0 +1,181 @@ +""" +Settings and API key management endpoints. +""" + +from flask import request, current_app +from .auth import require_api_key, generate_api_key, _hash_key +from .helpers import api_success, api_error + +# Keys that must NEVER be exposed via the API +_SENSITIVE_KEYS = { + "spotify.client_secret", + "soulseek.api_key", + "plex.token", + "jellyfin.api_key", + "navidrome.password", + "hydrabase.api_key", + "tidal_download.session", + "listenbrainz.token", +} + + +def register_routes(bp): + + # ---- Settings ---- + + @bp.route("/settings", methods=["GET"]) + @require_api_key + def get_settings(): + """Get current settings (sensitive values redacted).""" + try: + cfg = current_app.soulsync["config_manager"] + raw = dict(cfg.config_data) if hasattr(cfg, "config_data") else {} + + sanitized = _redact_sensitive(raw) + return api_success({"settings": sanitized}) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + @bp.route("/settings", methods=["PATCH"]) + @require_api_key + def update_settings(): + """Update settings (partial). + + Body: {"key": "value", ...} — dot-notation keys accepted. + """ + body = request.get_json(silent=True) or {} + if not body: + return api_error("BAD_REQUEST", "Empty body.", 400) + + try: + cfg = current_app.soulsync["config_manager"] + updated = [] + for key, value in body.items(): + # Block writing API keys through settings endpoint + if key == "api_keys": + continue + cfg.set(key, value) + updated.append(key) + + return api_success({"message": "Settings updated.", "updated_keys": updated}) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + # ---- API Key Management ---- + + @bp.route("/api-keys", methods=["GET"]) + @require_api_key + def list_api_keys(): + """List all API keys (prefix + label only, never the full key).""" + try: + cfg = current_app.soulsync["config_manager"] + keys = cfg.get("api_keys", []) + return api_success({ + "keys": [ + { + "id": k.get("id"), + "label": k.get("label", ""), + "key_prefix": k.get("key_prefix", ""), + "created_at": k.get("created_at"), + "last_used_at": k.get("last_used_at"), + } + for k in keys + ] + }) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + @bp.route("/api-keys", methods=["POST"]) + @require_api_key + def create_api_key(): + """Generate a new API key. + + Body: {"label": "My Bot"} + The raw key is returned ONCE in the response. + """ + body = request.get_json(silent=True) or {} + label = body.get("label", "") + + try: + cfg = current_app.soulsync["config_manager"] + raw_key, record = generate_api_key(label) + keys = cfg.get("api_keys", []) + keys.append(record) + cfg.set("api_keys", keys) + + return api_success({ + "key": raw_key, + "id": record["id"], + "label": record["label"], + "key_prefix": record["key_prefix"], + "created_at": record["created_at"], + }, status=201) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + @bp.route("/api-keys/", methods=["DELETE"]) + @require_api_key + def revoke_api_key(key_id): + """Revoke (delete) an API key by its ID.""" + try: + cfg = current_app.soulsync["config_manager"] + keys = cfg.get("api_keys", []) + original_len = len(keys) + keys = [k for k in keys if k.get("id") != key_id] + + if len(keys) == original_len: + return api_error("NOT_FOUND", "API key not found.", 404) + + cfg.set("api_keys", keys) + return api_success({"message": "API key revoked."}) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + # ---- Bootstrap endpoint (no auth required) ---- + + @bp.route("/api-keys/bootstrap", methods=["POST"]) + def bootstrap_api_key(): + """Generate the first API key when none exist (no auth required). + + This endpoint only works when zero API keys are configured. + Body: {"label": "My First Key"} + """ + try: + cfg = current_app.soulsync["config_manager"] + existing = cfg.get("api_keys", []) + if existing: + return api_error("FORBIDDEN", + "API keys already exist. Use an authenticated request to create more.", 403) + + body = request.get_json(silent=True) or {} + label = body.get("label", "Default") + + raw_key, record = generate_api_key(label) + cfg.set("api_keys", [record]) + + return api_success({ + "key": raw_key, + "id": record["id"], + "label": record["label"], + "key_prefix": record["key_prefix"], + "created_at": record["created_at"], + }, status=201) + except Exception as e: + return api_error("SETTINGS_ERROR", str(e), 500) + + +def _redact_sensitive(config, prefix=""): + """Recursively redact sensitive values from a config dict.""" + if not isinstance(config, dict): + return config + + result = {} + for key, value in config.items(): + full_key = f"{prefix}.{key}" if prefix else key + if any(full_key.startswith(s) for s in _SENSITIVE_KEYS): + result[key] = "***REDACTED***" + elif isinstance(value, dict): + result[key] = _redact_sensitive(value, full_key) + else: + result[key] = value + return result diff --git a/api/system.py b/api/system.py new file mode 100644 index 00000000..4a0ecb03 --- /dev/null +++ b/api/system.py @@ -0,0 +1,101 @@ +""" +System endpoints — status, activity feed, stats. +""" + +import time + +from flask import current_app +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/system/status", methods=["GET"]) + @require_api_key + def system_status(): + """Server status including uptime and service connectivity.""" + try: + app = current_app._get_current_object() + ctx = app.soulsync + + uptime_seconds = time.time() - getattr(app, "start_time", time.time()) + hours, remainder = divmod(int(uptime_seconds), 3600) + minutes, seconds = divmod(remainder, 60) + + spotify = ctx.get("spotify_client") + spotify_ok = bool(spotify and spotify.is_authenticated()) + + soulseek = ctx.get("soulseek_client") + soulseek_ok = bool(soulseek) + + hydrabase = ctx.get("hydrabase_client") + hydrabase_ok = False + if hydrabase: + try: + ws, _ = hydrabase.get_ws_and_lock() + hydrabase_ok = ws is not None and ws.connected + except Exception: + pass + + return api_success({ + "uptime": f"{hours}h {minutes}m {seconds}s", + "uptime_seconds": int(uptime_seconds), + "services": { + "spotify": spotify_ok, + "soulseek": soulseek_ok, + "hydrabase": hydrabase_ok, + }, + }) + except Exception as e: + return api_error("SYSTEM_ERROR", str(e), 500) + + @bp.route("/system/activity", methods=["GET"]) + @require_api_key + def system_activity(): + """Recent activity feed.""" + try: + from web_server import activity_feed + items = list(activity_feed) if activity_feed else [] + return api_success({"activities": items}) + except Exception as e: + return api_error("SYSTEM_ERROR", str(e), 500) + + @bp.route("/system/stats", methods=["GET"]) + @require_api_key + def system_stats(): + """Combined library + download statistics.""" + try: + from database.music_database import get_database + db = get_database() + lib_stats = db.get_statistics_for_server() + db_info = db.get_database_info_for_server() + + # Active download count + download_count = 0 + try: + from web_server import download_tasks, tasks_lock + with tasks_lock: + download_count = sum( + 1 for t in download_tasks.values() + if t.get("status") in ("downloading", "queued", "searching") + ) + except ImportError: + pass + + return api_success({ + "library": { + "artists": lib_stats.get("artists", 0), + "albums": lib_stats.get("albums", 0), + "tracks": lib_stats.get("tracks", 0), + }, + "database": { + "size_mb": db_info.get("database_size_mb"), + "last_update": db_info.get("last_update"), + }, + "downloads": { + "active": download_count, + }, + }) + except Exception as e: + return api_error("SYSTEM_ERROR", str(e), 500) diff --git a/api/watchlist.py b/api/watchlist.py new file mode 100644 index 00000000..6fec71e8 --- /dev/null +++ b/api/watchlist.py @@ -0,0 +1,92 @@ +""" +Watchlist endpoints — view, add, remove watched artists, trigger scans. +""" + +from flask import request, current_app +from database.music_database import get_database +from .auth import require_api_key +from .helpers import api_success, api_error + + +def register_routes(bp): + + @bp.route("/watchlist", methods=["GET"]) + @require_api_key + def list_watchlist(): + """List all watchlist artists.""" + try: + db = get_database() + artists = db.get_watchlist_artists() + return api_success({ + "artists": [_serialize_watchlist_artist(a) for a in artists] + }) + except Exception as e: + return api_error("WATCHLIST_ERROR", str(e), 500) + + @bp.route("/watchlist", methods=["POST"]) + @require_api_key + def add_to_watchlist(): + """Add an artist to the watchlist. + + Body: {"artist_id": "...", "artist_name": "..."} + """ + body = request.get_json(silent=True) or {} + artist_id = body.get("artist_id") + artist_name = body.get("artist_name") + + if not artist_id or not artist_name: + return api_error("BAD_REQUEST", "Missing 'artist_id' or 'artist_name'.", 400) + + try: + db = get_database() + ok = db.add_artist_to_watchlist(artist_id, artist_name) + if ok: + return api_success({"message": f"Added {artist_name} to watchlist."}, status=201) + return api_error("INTERNAL_ERROR", "Failed to add artist to watchlist.", 500) + except Exception as e: + return api_error("WATCHLIST_ERROR", str(e), 500) + + @bp.route("/watchlist/", methods=["DELETE"]) + @require_api_key + def remove_from_watchlist(artist_id): + """Remove an artist from the watchlist.""" + try: + db = get_database() + ok = db.remove_artist_from_watchlist(artist_id) + if ok: + return api_success({"message": "Artist removed from watchlist."}) + return api_error("NOT_FOUND", "Artist not found in watchlist.", 404) + except Exception as e: + return api_error("WATCHLIST_ERROR", str(e), 500) + + @bp.route("/watchlist/scan", methods=["POST"]) + @require_api_key + def trigger_scan(): + """Trigger a watchlist scan for new releases.""" + try: + from web_server import is_watchlist_actually_scanning + if is_watchlist_actually_scanning(): + return api_error("CONFLICT", "Watchlist scan is already running.", 409) + + from web_server import start_watchlist_scan + start_watchlist_scan() + return api_success({"message": "Watchlist scan started."}) + except ImportError: + return api_error("NOT_AVAILABLE", "Watchlist scan function not available.", 501) + except Exception as e: + return api_error("WATCHLIST_ERROR", str(e), 500) + + +def _serialize_watchlist_artist(a): + return { + "id": a.id, + "spotify_artist_id": a.spotify_artist_id, + "itunes_artist_id": a.itunes_artist_id, + "artist_name": a.artist_name, + "image_url": a.image_url, + "date_added": a.date_added.isoformat() if a.date_added else None, + "last_scan_timestamp": a.last_scan_timestamp.isoformat() if a.last_scan_timestamp else None, + "include_albums": a.include_albums, + "include_eps": a.include_eps, + "include_singles": a.include_singles, + } diff --git a/api/wishlist.py b/api/wishlist.py new file mode 100644 index 00000000..cd3fc98e --- /dev/null +++ b/api/wishlist.py @@ -0,0 +1,108 @@ +""" +Wishlist endpoints — view, add, remove, and trigger processing. +""" + +from flask import request +from .auth import require_api_key +from .helpers import api_success, api_error, parse_pagination, build_pagination + + +def register_routes(bp): + + @bp.route("/wishlist", methods=["GET"]) + @require_api_key + def list_wishlist(): + """List wishlist tracks with optional category filter.""" + category = request.args.get("category") # "singles" or "albums" + page, limit = parse_pagination(request) + + try: + from core.wishlist_service import get_wishlist_service + service = get_wishlist_service() + raw_tracks = service.get_wishlist_tracks_for_download() + + # Category filter + if category in ("singles", "albums"): + raw_tracks = [ + t for t in raw_tracks + if _track_category(t) == category + ] + + total = len(raw_tracks) + start = (page - 1) * limit + tracks = raw_tracks[start:start + limit] + + return api_success( + {"tracks": tracks}, + pagination=build_pagination(page, limit, total), + ) + except Exception as e: + return api_error("WISHLIST_ERROR", str(e), 500) + + @bp.route("/wishlist", methods=["POST"]) + @require_api_key + def add_to_wishlist(): + """Add a track to the wishlist. + + Body: {"spotify_track_data": {...}, "failure_reason": "...", "source_type": "..."} + """ + body = request.get_json(silent=True) or {} + track_data = body.get("spotify_track_data") + reason = body.get("failure_reason", "Added via API") + source_type = body.get("source_type", "api") + + if not track_data: + return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400) + + try: + from database.music_database import get_database + db = get_database() + ok = db.add_to_wishlist(track_data, failure_reason=reason, source_type=source_type) + if ok: + return api_success({"message": "Track added to wishlist."}, status=201) + return api_error("CONFLICT", "Track may already be in wishlist.", 409) + except Exception as e: + return api_error("WISHLIST_ERROR", str(e), 500) + + @bp.route("/wishlist/", methods=["DELETE"]) + @require_api_key + def remove_from_wishlist(track_id): + """Remove a track from the wishlist by its Spotify track ID.""" + try: + from database.music_database import get_database + db = get_database() + ok = db.remove_from_wishlist(track_id) + if ok: + return api_success({"message": "Track removed from wishlist."}) + return api_error("NOT_FOUND", "Track not found in wishlist.", 404) + except Exception as e: + return api_error("WISHLIST_ERROR", str(e), 500) + + @bp.route("/wishlist/process", methods=["POST"]) + @require_api_key + def process_wishlist(): + """Trigger wishlist download processing.""" + try: + from web_server import is_wishlist_actually_processing + if is_wishlist_actually_processing(): + return api_error("CONFLICT", "Wishlist processing is already running.", 409) + + from web_server import start_wishlist_missing_downloads + start_wishlist_missing_downloads() + return api_success({"message": "Wishlist processing started."}) + except ImportError: + return api_error("NOT_AVAILABLE", "Wishlist processing function not available.", 501) + except Exception as e: + return api_error("WISHLIST_ERROR", str(e), 500) + + +def _track_category(track): + """Determine if a wishlist track is a single or album track.""" + album_type = "" + if isinstance(track, dict): + sd = track.get("spotify_data", {}) + if isinstance(sd, dict): + album = sd.get("album", {}) + if isinstance(album, dict): + album_type = album.get("album_type", "") + return "albums" if album_type == "album" else "singles" diff --git a/requirements-webui.txt b/requirements-webui.txt index e1f094e0..8617a599 100644 --- a/requirements-webui.txt +++ b/requirements-webui.txt @@ -3,6 +3,7 @@ # Core web framework Flask>=3.0.0 +Flask-Limiter>=3.5.0 # Music service APIs spotipy>=2.23.0 diff --git a/requirements.txt b/requirements.txt index b8cbede7..73c9ec00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,6 @@ aiohttp>=3.9.0 unidecode>=1.3.8 yt-dlp>=2024.12.13 Flask>=3.0.0 +Flask-Limiter>=3.5.0 lrclibapi>=0.3.1 pyacoustid>=1.3.0 \ No newline at end of file diff --git a/web_server.py b/web_server.py index fcafc937..a0c74c61 100644 --- a/web_server.py +++ b/web_server.py @@ -198,6 +198,25 @@ except Exception as e: print(f"🔴 FATAL: Error initializing service clients: {e}") spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None +# --- Register Public REST API Blueprint (v1) --- +try: + from api import create_api_blueprint, limiter + limiter.init_app(app) + api_bp = create_api_blueprint() + app.register_blueprint(api_bp, url_prefix='/api/v1') + app.soulsync = { + 'spotify_client': spotify_client, + 'soulseek_client': soulseek_client, + 'tidal_client': tidal_client, + 'matching_engine': matching_engine, + 'config_manager': config_manager, + 'hydrabase_client': None, # updated after Hydrabase init + 'hydrabase_worker': None, # updated after Hydrabase init + } + print("✅ Public REST API v1 registered at /api/v1") +except Exception as e: + print(f"⚠️ Public REST API v1 failed to register: {e}") + # --- Global Streaming State Management --- # Thread-safe state tracking for streaming functionality stream_state = { @@ -2521,6 +2540,53 @@ def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now except Exception as e: print(f"Error adding activity item: {e}") +# --- Internal API Key Management (browser-only, no auth) --- +@app.route('/api/v1/api-keys-internal', methods=['GET']) +def list_api_keys_internal(): + """List API keys for the settings page (no auth required — same as all UI routes).""" + keys = config_manager.get('api_keys', []) + safe_keys = [ + { + "id": k.get("id"), + "label": k.get("label", ""), + "key_prefix": k.get("key_prefix", ""), + "created_at": k.get("created_at"), + "last_used_at": k.get("last_used_at"), + } + for k in keys + ] + return jsonify({"success": True, "data": {"keys": safe_keys}}) + +@app.route('/api/v1/api-keys-internal/generate', methods=['POST']) +def generate_api_key_internal(): + """Generate API key from settings page (no auth required).""" + from api.auth import generate_api_key + body = request.get_json(silent=True) or {} + label = body.get("label", "") + raw_key, record = generate_api_key(label) + keys = config_manager.get('api_keys', []) + keys.append(record) + config_manager.set('api_keys', keys) + return jsonify({"success": True, "data": { + "key": raw_key, + "id": record["id"], + "label": record["label"], + "key_prefix": record["key_prefix"], + "created_at": record["created_at"], + }}), 201 + +@app.route('/api/v1/api-keys-internal/revoke/', methods=['DELETE']) +def revoke_api_key_internal(key_id): + """Revoke API key from settings page (no auth required).""" + keys = config_manager.get('api_keys', []) + original_len = len(keys) + keys = [k for k in keys if k.get("id") != key_id] + if len(keys) == original_len: + return jsonify({"success": False, "error": {"message": "Key not found"}}), 404 + config_manager.set('api_keys', keys) + return jsonify({"success": True, "data": {"message": "API key revoked"}}) + + @app.route('/api/settings', methods=['GET', 'POST']) def handle_settings(): global tidal_client # Declare that we might modify the global instance @@ -28462,6 +28528,10 @@ try: hydrabase_worker.start() hydrabase_client = HydrabaseClient(get_ws_and_lock=_get_hydrabase_ws_and_lock) print("✅ Hydrabase P2P mirror worker and metadata client initialized") + # Update API blueprint references + if hasattr(app, 'soulsync'): + app.soulsync['hydrabase_client'] = hydrabase_client + app.soulsync['hydrabase_worker'] = hydrabase_worker except Exception as e: print(f"⚠️ Hydrabase initialization failed: {e}") hydrabase_worker = None diff --git a/webui/index.html b/webui/index.html index 639214e5..a58c900c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2925,6 +2925,43 @@ + +
+

SoulSync API

+
+

REST API Keys

+
+ Generate API keys to access SoulSync remotely via the /api/v1/ endpoints. + Keys are shown once at creation — store them securely. +
+ + +
+
Loading...
+
+ + +
+ + +
+
+ +
+ + + +
+
+

Server Connections

diff --git a/webui/static/script.js b/webui/static/script.js index a9f27900..c7345a1f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -706,6 +706,7 @@ async function loadPageData(pageId) { initializeSettings(); await loadSettingsData(); await loadQualityProfile(); + loadApiKeys(); break; case 'import': initializeImportPage(); @@ -2778,6 +2779,118 @@ async function clearQuarantine() { } } +// ======================== API Key Management ======================== + +async function loadApiKeys() { + const container = document.getElementById('api-keys-list'); + if (!container) return; + + try { + const response = await fetch('/api/v1/api-keys-internal'); + if (response.ok) { + const data = await response.json(); + renderApiKeys(data.data?.keys || []); + } else { + container.innerHTML = '
No API keys configured.
'; + } + } catch (e) { + container.innerHTML = '
No API keys configured.
'; + } +} + +function renderApiKeys(keys) { + const container = document.getElementById('api-keys-list'); + if (!container) return; + + if (!keys || keys.length === 0) { + container.innerHTML = '
No API keys yet. Generate one below.
'; + return; + } + + container.innerHTML = keys.map(k => ` +
+
+
${k.label || 'Unnamed'}
+
+ ${k.key_prefix || 'sk_...'}... + · Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'} + ${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''} +
+
+ +
+ `).join(''); +} + +async function generateApiKey() { + const labelInput = document.getElementById('api-key-label'); + const label = labelInput ? labelInput.value.trim() : ''; + + try { + const response = await fetch('/api/v1/api-keys-internal/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: label || 'Default' }) + }); + const data = await response.json(); + + if (data.success && data.data?.key) { + const keyDisplay = document.getElementById('api-key-generated'); + const keyValue = document.getElementById('api-key-value'); + if (keyDisplay && keyValue) { + keyValue.textContent = data.data.key; + keyDisplay.style.display = 'block'; + } + if (labelInput) labelInput.value = ''; + showToast('API key generated! Copy it now.', 'success'); + loadApiKeys(); + } else { + showToast(data.error?.message || 'Failed to generate API key', 'error'); + } + } catch (error) { + console.error('Error generating API key:', error); + showToast('Failed to generate API key', 'error'); + } +} + +function copyApiKey() { + const keyValue = document.getElementById('api-key-value'); + if (keyValue) { + navigator.clipboard.writeText(keyValue.textContent).then(() => { + showToast('API key copied to clipboard', 'success'); + }).catch(() => { + // Fallback for older browsers + const range = document.createRange(); + range.selectNode(keyValue); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + document.execCommand('copy'); + showToast('API key copied', 'success'); + }); + } +} + +async function revokeApiKey(keyId, label) { + if (!confirm(`Revoke API key "${label}"? Any apps using this key will stop working.`)) return; + + try { + const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showToast('API key revoked', 'success'); + loadApiKeys(); + } else { + showToast(data.error?.message || 'Failed to revoke key', 'error'); + } + } catch (error) { + console.error('Error revoking API key:', error); + showToast('Failed to revoke key', 'error'); + } +} + // Dashboard-specific test functions that create activity items async function testDashboardConnection(service) { try {