diff --git a/.gitignore b/.gitignore index 3bf4024..3220168 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ # Docker crap *.log docker-compose.override.yml + +VSCode +.vscode/ diff --git a/src/sonobarr_app/__init__.py b/src/sonobarr_app/__init__.py index 612ce17..f32157c 100644 --- a/src/sonobarr_app/__init__.py +++ b/src/sonobarr_app/__init__.py @@ -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) diff --git a/src/sonobarr_app/config.py b/src/sonobarr_app/config.py index 432ab8e..895a429 100644 --- a/src/sonobarr_app/config.py +++ b/src/sonobarr_app/config.py @@ -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) diff --git a/src/sonobarr_app/services/data_handler.py b/src/sonobarr_app/services/data_handler.py index 3131646..d4d6bba 100644 --- a/src/sonobarr_app/services/data_handler.py +++ b/src/sonobarr_app/services/data_handler.py @@ -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 "" diff --git a/src/sonobarr_app/web/__init__.py b/src/sonobarr_app/web/__init__.py index cb8fc41..85ad484 100644 --- a/src/sonobarr_app/web/__init__.py +++ b/src/sonobarr_app/web/__init__.py @@ -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"] diff --git a/src/sonobarr_app/web/api.py b/src/sonobarr_app/web/api.py new file mode 100644 index 0000000..11e5048 --- /dev/null +++ b/src/sonobarr_app/web/api.py @@ -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 diff --git a/src/static/script.js b/src/static/script.js index bb2011e..4043cb4 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -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 || ''; } diff --git a/src/templates/base.html b/src/templates/base.html index ca52ab0..c9766e9 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -148,6 +148,11 @@ +