Add SoulSync REST API (v1) with API key authentication

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.
This commit is contained in:
Broque Thomas 2026-03-03 09:49:00 -08:00
parent 0b9fe879f8
commit d9aa8303a7
17 changed files with 1907 additions and 0 deletions

443
Support/API.md Normal file
View file

@ -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/<artist_id>`
Get artist details with album list.
#### `GET /api/v1/library/artists/<artist_id>/albums`
List albums for an artist.
#### `GET /api/v1/library/albums/<album_id>/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/<download_id>/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/<track_id>`
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/<artist_id>`
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/<playlist_id>`
Get playlist details with tracks.
#### `POST /api/v1/playlists/<playlist_id>/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/<key_id>`
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"
```

72
api/__init__.py Normal file
View file

@ -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

76
api/auth.py Normal file
View file

@ -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 <key> 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

81
api/downloads.py Normal file
View file

@ -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/<download_id>/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)

51
api/helpers.py Normal file
View file

@ -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

158
api/library.py Normal file
View file

@ -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/<artist_id>", 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/<artist_id>/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/<album_id>/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,
}

152
api/playlists.py Normal file
View file

@ -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/<playlist_id>", 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/<playlist_id>/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)

170
api/search.py Normal file
View file

@ -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,
}

181
api/settings.py Normal file
View file

@ -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/<key_id>", 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

101
api/system.py Normal file
View file

@ -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)

92
api/watchlist.py Normal file
View file

@ -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/<artist_id>", 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,
}

108
api/wishlist.py Normal file
View file

@ -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/<track_id>", 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"

View file

@ -3,6 +3,7 @@
# Core web framework
Flask>=3.0.0
Flask-Limiter>=3.5.0
# Music service APIs
spotipy>=2.23.0

View file

@ -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

View file

@ -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/<key_id>', 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

View file

@ -2925,6 +2925,43 @@
</div>
</div>
<!-- SoulSync API Keys -->
<div class="settings-group">
<h3>SoulSync API</h3>
<div class="api-service-frame">
<h4 class="service-title" style="color: #64ffda;">REST API Keys</h4>
<div class="setting-help-text" style="margin-bottom: 12px; color: #888; font-size: 12px;">
Generate API keys to access SoulSync remotely via the <code>/api/v1/</code> endpoints.
Keys are shown once at creation — store them securely.
</div>
<!-- Existing API Keys List -->
<div id="api-keys-list" style="margin-bottom: 12px;">
<div style="color: #666; font-size: 13px; padding: 8px 0;">Loading...</div>
</div>
<!-- Generate New Key -->
<div class="form-group" style="margin-bottom: 8px;">
<label>New Key Label:</label>
<input type="text" id="api-key-label" placeholder="e.g. Discord Bot, Home Assistant">
</div>
<div class="form-actions">
<button class="auth-button" onclick="generateApiKey()">🔑 Generate API Key</button>
</div>
<!-- Newly Generated Key Display (hidden by default) -->
<div id="api-key-generated" style="display: none; margin-top: 12px; padding: 12px; background: rgba(100, 255, 218, 0.08); border: 1px solid rgba(100, 255, 218, 0.2); border-radius: 8px;">
<div style="font-size: 11px; color: #64ffda; margin-bottom: 6px; font-weight: 600;">
Copy this key now — it won't be shown again!
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<code id="api-key-value" style="flex: 1; font-size: 12px; word-break: break-all; color: #fff; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;"></code>
<button onclick="copyApiKey()" style="padding: 6px 12px; background: rgba(100,255,218,0.15); border: 1px solid rgba(100,255,218,0.3); color: #64ffda; border-radius: 4px; cursor: pointer; white-space: nowrap;">Copy</button>
</div>
</div>
</div>
</div>
<!-- Server Connections -->
<div class="settings-group">
<h3>Server Connections</h3>

View file

@ -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 = '<div style="color: #666; font-size: 13px;">No API keys configured.</div>';
}
} catch (e) {
container.innerHTML = '<div style="color: #666; font-size: 13px;">No API keys configured.</div>';
}
}
function renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
if (!container) return;
if (!keys || keys.length === 0) {
container.innerHTML = '<div style="color: #666; font-size: 13px; padding: 4px 0;">No API keys yet. Generate one below.</div>';
return;
}
container.innerHTML = keys.map(k => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 4px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;">
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; color: #e0e0e0; font-weight: 500;">${k.label || 'Unnamed'}</div>
<div style="font-size: 11px; color: #666; margin-top: 2px;">
<code>${k.key_prefix || 'sk_...'}...</code>
&middot; Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
${k.last_used_at ? '&middot; Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
</div>
</div>
<button onclick="revokeApiKey('${k.id}', '${(k.label || 'this key').replace(/'/g, "\\'")}')"
style="padding: 4px 10px; background: rgba(255,82,82,0.1); border: 1px solid rgba(255,82,82,0.2); color: #ff5252; border-radius: 4px; cursor: pointer; font-size: 11px; white-space: nowrap;">
Revoke
</button>
</div>
`).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 {