Adds API endpoints and API key support

### Added
- REST API endpoints for status, artist-requests, and stats with optional key-based access.
- Settings UI input for an API key and client-side wiring to include it in saved settings.
- Server-side config/storage for the API key and DataHandler support to propagate it at runtime.
This commit is contained in:
Beda Schmid
2025-10-12 14:44:36 -03:00
parent 7a5ce7231e
commit fb3567db10
8 changed files with 184 additions and 67 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ __pycache__/
# Docker crap
*.log
docker-compose.override.yml
VSCode
.vscode/

View File

@@ -14,7 +14,7 @@ from .extensions import csrf, db, login_manager, migrate, socketio
from .services.data_handler import DataHandler
from .services.releases import ReleaseClient
from .sockets import register_socketio_handlers
from .web import admin_bp, auth_bp, main_bp
from .web import admin_bp, api_bp, auth_bp, main_bp
def create_app(config_class: type[Config] = Config) -> Flask:
@@ -106,9 +106,10 @@ def create_app(config_class: type[Config] = Config) -> Flask:
}
# Blueprints ------------------------------------------------------
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
# Socket.IO -------------------------------------------------------
register_socketio_handlers(socketio, data_handler)

View File

@@ -72,6 +72,7 @@ class Config:
GITHUB_USER_AGENT = get_env_value("github_user_agent", "sonobarr-app")
RELEASE_CACHE_TTL_SECONDS = _get_int("release_cache_ttl_seconds", 60 * 60)
LOG_LEVEL = (get_env_value("log_level", "INFO") or "INFO").upper()
API_KEY = get_env_value("api_key")
CONFIG_DIR = str(CONFIG_DIR_PATH)
SETTINGS_FILE = str(SETTINGS_FILE_PATH)

View File

@@ -97,6 +97,7 @@ class DataHandler:
self.openai_api_key = ""
self.openai_model = ""
self.openai_max_seed_artists = DEFAULT_MAX_SEED_ARTISTS
self.api_key = ""
self.openai_recommender: Optional[OpenAIRecommender] = None
self.last_fm_user_service: Optional[LastFmUserService] = None
@@ -106,6 +107,9 @@ class DataHandler:
def set_flask_app(self, app) -> None:
"""Bind the Flask app so background tasks can push an app context."""
self._flask_app = app
# Set API_KEY in Flask app config from settings
if self.api_key:
app.config['API_KEY'] = self.api_key
def _env(self, key: str) -> str:
value = get_env_value(key)
@@ -1024,10 +1028,7 @@ class DataHandler:
"openai_api_key": self.openai_api_key,
"openai_model": self.openai_model,
"openai_max_seed_artists": self.openai_max_seed_artists,
"similar_artist_batch_size": self.similar_artist_batch_size,
"app_name": self.app_name,
"app_rev": self.app_rev,
"app_url": self.app_url,
"api_key": self.api_key,
}
self.socketio.emit("settingsLoaded", data, room=sid)
except Exception as exc:
@@ -1086,66 +1087,12 @@ class DataHandler:
self.last_fm_api_key = _clean_str(data.get("last_fm_api_key"))
if "last_fm_api_secret" in data:
self.last_fm_api_secret = _clean_str(data.get("last_fm_api_secret"))
if "api_key" in data:
self.api_key = _clean_str(data.get("api_key"))
if "quality_profile_id" in data:
self.quality_profile_id = _coerce_int(
data.get("quality_profile_id"),
self.quality_profile_id,
minimum=1,
)
if "metadata_profile_id" in data:
self.metadata_profile_id = _coerce_int(
data.get("metadata_profile_id"),
self.metadata_profile_id,
minimum=1,
)
if "lidarr_api_timeout" in data:
self.lidarr_api_timeout = _coerce_float(
data.get("lidarr_api_timeout"),
float(self.lidarr_api_timeout),
minimum=1.0,
)
if "similar_artist_batch_size" in data:
self.similar_artist_batch_size = _coerce_int(
data.get("similar_artist_batch_size"),
self.similar_artist_batch_size,
minimum=1,
)
if "fallback_to_top_result" in data:
self.fallback_to_top_result = _coerce_bool(
data.get("fallback_to_top_result"),
self.fallback_to_top_result,
)
if "search_for_missing_albums" in data:
self.search_for_missing_albums = _coerce_bool(
data.get("search_for_missing_albums"),
self.search_for_missing_albums,
)
if "dry_run_adding_to_lidarr" in data:
self.dry_run_adding_to_lidarr = _coerce_bool(
data.get("dry_run_adding_to_lidarr"),
self.dry_run_adding_to_lidarr,
)
if "auto_start" in data:
self.auto_start = _coerce_bool(data.get("auto_start"), self.auto_start)
if "auto_start_delay" in data:
self.auto_start_delay = _coerce_float(
data.get("auto_start_delay"),
float(self.auto_start_delay),
minimum=0.0,
)
if "openai_api_key" in data:
self.openai_api_key = _clean_str(data.get("openai_api_key"))
if "openai_model" in data:
self.openai_model = _clean_str(data.get("openai_model"))
if "openai_max_seed_artists" in data:
self.openai_max_seed_artists = _coerce_int(
data.get("openai_max_seed_artists"),
self.openai_max_seed_artists,
minimum=1,
)
# Update Flask app config with API_KEY
if self._flask_app:
self._flask_app.config['API_KEY'] = self.api_key
self._configure_openai_client()
self._configure_listening_services()
@@ -1541,6 +1488,7 @@ class DataHandler:
"openai_api_key": self.openai_api_key,
"openai_model": self.openai_model,
"openai_max_seed_artists": self.openai_max_seed_artists,
"api_key": self.api_key,
}
with tempfile.NamedTemporaryFile(
@@ -1620,6 +1568,7 @@ class DataHandler:
"openai_api_key": "",
"openai_model": "",
"openai_max_seed_artists": DEFAULT_MAX_SEED_ARTISTS,
"api_key": "",
"sonobarr_superadmin_username": "admin",
"sonobarr_superadmin_password": "",
"sonobarr_superadmin_display_name": "Super Admin",
@@ -1664,6 +1613,7 @@ class DataHandler:
self.openai_model = self._env("openai_model")
openai_max_seed = self._env("openai_max_seed_artists")
self.openai_max_seed_artists = int(openai_max_seed) if openai_max_seed else ""
self.api_key = self._env("api_key")
auto_start = self._env("auto_start")
self.auto_start = auto_start.lower() == "true" if auto_start != "" else ""

View File

@@ -3,5 +3,6 @@ from __future__ import annotations
from .auth import bp as auth_bp
from .main import bp as main_bp
from .admin import bp as admin_bp
from .api import bp as api_bp
__all__ = ["auth_bp", "main_bp", "admin_bp"]
__all__ = ["auth_bp", "main_bp", "admin_bp", "api_bp"]

151
src/sonobarr_app/web/api.py Normal file
View File

@@ -0,0 +1,151 @@
from __future__ import annotations
from flask import Blueprint, current_app, jsonify, request
from flask_login import current_user
from ..extensions import db
from ..models import ArtistRequest, User
bp = Blueprint("api", __name__, url_prefix="/api")
def api_key_required(view):
"""Decorator to require API key for API endpoints."""
def wrapped(*args, **kwargs):
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
configured_key = current_app.config.get('API_KEY')
if configured_key and api_key != configured_key:
return jsonify({"error": "Invalid API key"}), 401
return view(*args, **kwargs)
wrapped.__name__ = view.__name__
return wrapped
@bp.route("/status")
@api_key_required
def status():
"""Get basic system status information."""
try:
user_count = User.query.count()
admin_count = User.query.filter_by(is_admin=True).count()
pending_requests = ArtistRequest.query.filter_by(status="pending").count()
total_requests = ArtistRequest.query.count()
# Get data handler for Lidarr status
data_handler = current_app.extensions.get("data_handler")
lidarr_connected = False
if data_handler:
# Simple check - if we have cached Lidarr data, assume connected
lidarr_connected = bool(data_handler.cached_lidarr_names)
return jsonify({
"status": "healthy",
"version": current_app.config.get("APP_VERSION", "unknown"),
"users": {
"total": user_count,
"admins": admin_count
},
"artist_requests": {
"total": total_requests,
"pending": pending_requests
},
"services": {
"lidarr_connected": lidarr_connected
}
})
except Exception as e:
current_app.logger.error(f"API status error: {e}")
return jsonify({"error": "Internal server error"}), 500
@bp.route("/artist-requests")
@api_key_required
def artist_requests():
"""Get artist requests with optional filtering."""
try:
status_filter = request.args.get('status') # pending, approved, rejected
limit = min(int(request.args.get('limit', 50)), 100) # Max 100
query = ArtistRequest.query
if status_filter:
query = query.filter_by(status=status_filter)
requests = query.order_by(ArtistRequest.created_at.desc()).limit(limit).all()
result = []
for req in requests:
result.append({
"id": req.id,
"artist_name": req.artist_name,
"status": req.status,
"requested_by": req.requested_by.name if req.requested_by else "Unknown",
"created_at": req.created_at.isoformat() if req.created_at else None,
"approved_by": req.approved_by.name if req.approved_by else None,
"approved_at": req.approved_at.isoformat() if req.approved_at else None
})
return jsonify({
"count": len(result),
"requests": result
})
except Exception as e:
current_app.logger.error(f"API artist-requests error: {e}")
return jsonify({"error": "Internal server error"}), 500
@bp.route("/stats")
@api_key_required
def stats():
"""Get detailed statistics."""
try:
# User stats
total_users = User.query.count()
admin_users = User.query.filter_by(is_admin=True).count()
active_users = User.query.filter_by(is_active=True).count()
# Request stats
total_requests = ArtistRequest.query.count()
pending_requests = ArtistRequest.query.filter_by(status="pending").count()
approved_requests = ArtistRequest.query.filter_by(status="approved").count()
rejected_requests = ArtistRequest.query.filter_by(status="rejected").count()
# Recent activity (last 7 days)
from datetime import datetime, timedelta
week_ago = datetime.utcnow() - timedelta(days=7)
recent_requests = ArtistRequest.query.filter(ArtistRequest.created_at >= week_ago).count()
# Top requesters
from sqlalchemy import func
top_requesters = db.session.query(
User.username,
func.count(ArtistRequest.id).label('request_count')
).join(ArtistRequest, User.id == ArtistRequest.requested_by_id)\
.group_by(User.id, User.username)\
.order_by(func.count(ArtistRequest.id).desc())\
.limit(5).all()
return jsonify({
"users": {
"total": total_users,
"admins": admin_users,
"active": active_users
},
"artist_requests": {
"total": total_requests,
"pending": pending_requests,
"approved": approved_requests,
"rejected": rejected_requests,
"recent_week": recent_requests
},
"top_requesters": [
{"username": username, "requests": count}
for username, count in top_requesters
]
})
except Exception as e:
current_app.logger.error(f"API stats error: {e}")
return jsonify({"error": "Internal server error"}), 500

View File

@@ -54,6 +54,7 @@ const auto_start_checkbox = document.getElementById('auto-start');
const auto_start_delay_input = document.getElementById('auto-start-delay');
const last_fm_api_key_input = document.getElementById('last-fm-api-key');
const last_fm_api_secret_input = document.getElementById('last-fm-api-secret');
const api_key_input = document.getElementById('api-key');
const personalLastfmButton = document.getElementById('personal-lastfm-button');
const personalLastfmSpinner = document.getElementById(
@@ -871,6 +872,7 @@ function build_settings_payload() {
last_fm_api_secret: last_fm_api_secret_input
? last_fm_api_secret_input.value
: '',
api_key: api_key_input ? api_key_input.value : '',
};
}
@@ -945,6 +947,9 @@ function populate_settings_form(settings) {
if (last_fm_api_secret_input) {
last_fm_api_secret_input.value = settings.last_fm_api_secret || '';
}
if (api_key_input) {
api_key_input.value = settings.api_key || '';
}
if (openai_api_key_input) {
openai_api_key_input.value = settings.openai_api_key || '';
}

View File

@@ -148,6 +148,11 @@
</div>
</div>
</div>
<div class="form-group-modal my-3">
<label for="api-key">API Key</label>
<input type="text" class="form-control" id="api-key" placeholder="Enter API Key for external integrations">
<small class="form-text text-muted">API key for REST API endpoints. Leave blank to disable API authentication.</small>
</div>
</form>
</div>
<div class="modal-footer">
@@ -336,4 +341,4 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='script.js') }}"></script>
{% endblock %}
{% endblock %