mirror of
https://github.com/Dodelidoo-Labs/sonobarr.git
synced 2025-12-23 22:17:45 -05:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ __pycache__/
|
||||
# Docker crap
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
|
||||
VSCode
|
||||
.vscode/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
151
src/sonobarr_app/web/api.py
Normal 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
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
@@ -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 %
|
||||
|
||||
Reference in New Issue
Block a user