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:
parent
0b9fe879f8
commit
d9aa8303a7
17 changed files with 1907 additions and 0 deletions
443
Support/API.md
Normal file
443
Support/API.md
Normal 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
72
api/__init__.py
Normal 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
76
api/auth.py
Normal 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
81
api/downloads.py
Normal 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
51
api/helpers.py
Normal 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
158
api/library.py
Normal 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
152
api/playlists.py
Normal 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
170
api/search.py
Normal 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
181
api/settings.py
Normal 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
101
api/system.py
Normal 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
92
api/watchlist.py
Normal 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
108
api/wishlist.py
Normal 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"
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
# Core web framework
|
||||
Flask>=3.0.0
|
||||
Flask-Limiter>=3.5.0
|
||||
|
||||
# Music service APIs
|
||||
spotipy>=2.23.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
· Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
|
||||
${k.last_used_at ? '· 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue