mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-11 01:26:37 +00:00
Merge origin/main into fix/starlark-apps-dir-ownership
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,20 +85,12 @@ def _scrub_git_remote_url(url: str) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
return url
|
||||
# Will be initialized when blueprint is registered
|
||||
# NOTE: the managers live on the blueprint object (app.py sets
|
||||
# api_v3.config_manager / api_v3.plugin_manager). Deliberately not
|
||||
# mirrored as module globals: a bare `config_manager` used to resolve to
|
||||
# a None that was never assigned, which silently disabled the /health
|
||||
# checks and made /display/current fall back to a hardcoded 128x64.
|
||||
plugin_store_manager = None
|
||||
saved_repositories_manager = None
|
||||
cache_manager = None
|
||||
schema_manager = None
|
||||
operation_queue = None
|
||||
plugin_state_manager = None
|
||||
operation_history = None
|
||||
sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available)
|
||||
# api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and
|
||||
# the rest). Deliberately not mirrored as module globals: a bare
|
||||
# `config_manager` used to resolve to a None that was never assigned, which
|
||||
# silently disabled the /health checks and made /display/current fall back
|
||||
# to a hardcoded 128x64.
|
||||
# Get project root directory (web_interface/../..)
|
||||
# web_interface/blueprints/api_v3/_common.py -> up four to the project root.
|
||||
# This was three levels when everything lived in web_interface/blueprints/api_v3.py;
|
||||
@@ -156,13 +148,6 @@ def _is_plugin_update_available(installed_version: str, latest_version: str) ->
|
||||
"""
|
||||
from src.plugin_system.compatibility import is_update_available
|
||||
return is_update_available(installed_version, latest_version)
|
||||
def _ensure_cache_manager():
|
||||
"""Ensure cache manager is initialized."""
|
||||
global cache_manager
|
||||
if cache_manager is None:
|
||||
from src.cache_manager import CacheManager
|
||||
cache_manager = CacheManager()
|
||||
return cache_manager
|
||||
def _save_config_atomic(config_manager, config_data, create_backup=True):
|
||||
"""
|
||||
Save configuration using atomic save if available, fallback to regular save.
|
||||
|
||||
@@ -5,8 +5,7 @@ endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
||||
datetime, json, jsonify, logger, os, plugin_store_manager, request,
|
||||
tempfile,
|
||||
datetime, json, jsonify, logger, os, request, tempfile,
|
||||
)
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -151,7 +150,7 @@ def backup_restore():
|
||||
|
||||
# Reinstall plugins if requested and store manager available
|
||||
if options.reinstall_plugins and result.plugins_to_install:
|
||||
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
||||
psm = getattr(api_v3, 'plugin_store_manager', None)
|
||||
for plug in result.plugins_to_install:
|
||||
pid = plug.get('plugin_id')
|
||||
if not pid:
|
||||
|
||||
@@ -985,6 +985,10 @@ def save_main_config():
|
||||
mode_key = field[len('duration__'):]
|
||||
if not mode_key:
|
||||
continue
|
||||
if raw_value is None or str(raw_value).strip() == '':
|
||||
# Blank means "use the plugin's own duration".
|
||||
current_config['display']['display_durations'].pop(mode_key, None)
|
||||
continue
|
||||
try:
|
||||
int_value = int(raw_value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
|
||||
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||
endpoint names are unchanged by living here.
|
||||
"""
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_ensure_cache_manager, _ensure_display_service_running,
|
||||
_ensure_display_service_running,
|
||||
_get_display_service_status, _stop_display_service, api_v3,
|
||||
describe_exception, jsonify, logger, os, request, uuid,
|
||||
)
|
||||
@@ -15,6 +15,19 @@ import web_interface.blueprints.api_v3 as _pkg
|
||||
# package is the only patch point that covers every caller.
|
||||
|
||||
|
||||
def _cache_manager():
|
||||
"""The web process's CacheManager, the one app.py puts on the blueprint.
|
||||
|
||||
Created on first use when nothing set it (a test app, an embedder), and
|
||||
stored back on the blueprint so every route shares that one instance.
|
||||
"""
|
||||
cache = getattr(api_v3, 'cache_manager', None)
|
||||
if cache is None:
|
||||
from src.cache_manager import CacheManager
|
||||
cache = api_v3.cache_manager = CacheManager()
|
||||
return cache
|
||||
|
||||
|
||||
@api_v3.route('/display/current', methods=['GET'])
|
||||
def get_display_current():
|
||||
"""Get current display state"""
|
||||
@@ -137,7 +150,7 @@ def get_display_modes():
|
||||
def get_on_demand_status():
|
||||
"""Return the current on-demand display state."""
|
||||
try:
|
||||
cache = _ensure_cache_manager()
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: the display service writes this key, so only the file
|
||||
# is current. This process's memory tier would keep serving the first
|
||||
# copy it read for the full max_age -- "active" for two minutes after
|
||||
@@ -209,7 +222,7 @@ def start_on_demand_display():
|
||||
|
||||
# Set the on-demand request in cache FIRST (before starting service)
|
||||
# This ensures the request is available when the service starts/restarts
|
||||
cache = _ensure_cache_manager()
|
||||
cache = _cache_manager()
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
@@ -277,7 +290,7 @@ def stop_on_demand_display():
|
||||
|
||||
# Set the stop request in cache FIRST
|
||||
# The display controller will poll this and restart without the on-demand filter
|
||||
cache = _ensure_cache_manager()
|
||||
cache = _cache_manager()
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
@@ -313,7 +326,7 @@ def get_current_display_status():
|
||||
process directly.
|
||||
"""
|
||||
try:
|
||||
cache = _ensure_cache_manager()
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
||||
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
||||
if state is None:
|
||||
|
||||
@@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import (
|
||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||
_get_display_service_status, _mqtt_bridge_service_state,
|
||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
||||
error_response, json, jsonify, logger, os, redact_text, request,
|
||||
subprocess, success_response, tempfile,
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
from src import error_aggregator as _errors
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
@@ -326,17 +327,62 @@ def delete_cache_file():
|
||||
except Exception as e:
|
||||
logger.error('Error in delete_cache_file', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _errors_cache():
|
||||
"""The shared cache the display service publishes its errors to."""
|
||||
if not api_v3.cache_manager:
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
return api_v3.cache_manager
|
||||
|
||||
|
||||
def _redact_error_text(text, keep_lines=False):
|
||||
"""Credentials out of plugin exception text, which can quote a URL with
|
||||
an API key in it. Stack traces keep their line breaks and indentation."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
if not keep_lines:
|
||||
return redact_text(text, max_length=len(text) + 1)
|
||||
return '\n'.join(
|
||||
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
|
||||
for line in text.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _redact_error_record(record):
|
||||
if not isinstance(record, dict):
|
||||
return record
|
||||
record = dict(record)
|
||||
record['message'] = _redact_error_text(record.get('message'))
|
||||
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
|
||||
if isinstance(record.get('context'), dict):
|
||||
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
|
||||
return record
|
||||
|
||||
|
||||
def _read_errors():
|
||||
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
||||
return snapshot, clear_request
|
||||
|
||||
|
||||
@api_v3.route('/errors/summary', methods=['GET'])
|
||||
def get_error_summary():
|
||||
"""
|
||||
Get summary of all errors for monitoring and debugging.
|
||||
|
||||
Returns error counts, detected patterns, and recent errors.
|
||||
Returns error counts, detected patterns, and recent errors, as last
|
||||
reported by the display service (which runs the plugins, so it is the
|
||||
only process that records their errors). ``snapshot_available`` is false
|
||||
until it has reported; ``generated_at`` says when it did.
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
summary = aggregator.get_error_summary()
|
||||
return success_response(data=summary, message="Error summary retrieved")
|
||||
summary = _errors.error_summary_from_report(*_read_errors())
|
||||
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
||||
for pattern in summary['active_patterns'].values():
|
||||
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
||||
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
|
||||
message = ("Error summary retrieved" if summary['snapshot_available']
|
||||
else "The display service has not reported any errors yet")
|
||||
return success_response(data=summary, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||
return error_response(
|
||||
@@ -352,11 +398,13 @@ def get_plugin_errors(plugin_id):
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns health status and error statistics for the plugin.
|
||||
Returns health status and error statistics for the plugin, from the
|
||||
display service's last report (see get_error_summary). A plugin with no
|
||||
recorded errors is "healthy".
|
||||
"""
|
||||
try:
|
||||
aggregator = get_error_aggregator()
|
||||
health = aggregator.get_plugin_health(plugin_id)
|
||||
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
||||
health['last_error'] = _redact_error_record(health['last_error'])
|
||||
return success_response(data=health, message="Plugin health retrieved")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||
@@ -372,42 +420,61 @@ def clear_old_errors():
|
||||
|
||||
Request body (optional):
|
||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||
all: true clears every error recorded so far (max_age_hours ignored)
|
||||
|
||||
The errors live in the display service, so this records a clear request
|
||||
that it applies within a few seconds. Reads hide the cleared errors from
|
||||
the moment the request is recorded.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
clear_all = _coerce_to_bool(data.get('all'))
|
||||
raw_max_age = data.get('max_age_hours', 24)
|
||||
|
||||
# Validate and coerce max_age_hours
|
||||
max_age_hours = None
|
||||
if not clear_all:
|
||||
try:
|
||||
max_age_hours = int(raw_max_age)
|
||||
if max_age_hours < 1:
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be at least 1",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
if max_age_hours > 8760: # 1 year max
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
context={'provided_value': str(raw_max_age)},
|
||||
status_code=400
|
||||
)
|
||||
|
||||
now = _pkg.time.time()
|
||||
cutoff = now if clear_all else now - max_age_hours * 3600
|
||||
try:
|
||||
max_age_hours = int(raw_max_age)
|
||||
if max_age_hours < 1:
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be at least 1",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
if max_age_hours > 8760: # 1 year max
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||
context={'provided_value': raw_max_age},
|
||||
status_code=400
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
||||
except OSError as e:
|
||||
logger.error("Could not record an error clear request: %s", e)
|
||||
return error_response(
|
||||
error_code=ErrorCode.INVALID_INPUT,
|
||||
message="max_age_hours must be a valid integer",
|
||||
context={'provided_value': str(raw_max_age)},
|
||||
status_code=400
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message="Could not record the clear request in the shared cache",
|
||||
status_code=500
|
||||
)
|
||||
|
||||
aggregator = get_error_aggregator()
|
||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
||||
|
||||
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||
return success_response(
|
||||
data={'cleared_count': cleared_count},
|
||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
||||
data=result,
|
||||
message=(f"Clear of {scope} requested; the display service applies it "
|
||||
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||
|
||||
@@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
api_v3, datetime, deep_merge, describe_exception, error_response,
|
||||
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
||||
merge_secrets, os, redact_text, remove_empty_secrets, request,
|
||||
separate_secrets, shutil, stat, subprocess, success_response, sys,
|
||||
separate_secrets, shutil, stat, subprocess, success_response,
|
||||
tempfile, uuid, validate_request_json,
|
||||
)
|
||||
from src.common.path_safety import (
|
||||
@@ -409,6 +409,8 @@ def manage_plugin_limits(plugin_id):
|
||||
@api_v3.route('/plugins/toggle', methods=['POST'])
|
||||
def toggle_plugin():
|
||||
"""Toggle plugin enabled/disabled"""
|
||||
plugin_id = None
|
||||
enabled = None
|
||||
try:
|
||||
if not api_v3.plugin_manager or not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
|
||||
@@ -506,21 +508,21 @@ def toggle_plugin():
|
||||
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
from src.web_interface.errors import WebInterfaceError
|
||||
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT)
|
||||
# Not PLUGIN_OPERATION_CONFLICT: that told the user "an operation is
|
||||
# already in progress" whatever actually went wrong.
|
||||
logger.error('Error toggling plugin %s', plugin_id, exc_info=True)
|
||||
if api_v3.operation_history:
|
||||
toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"
|
||||
api_v3.operation_history.record_operation(
|
||||
toggle_type,
|
||||
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
|
||||
"enable" if enabled else "disable",
|
||||
plugin_id=plugin_id,
|
||||
status="failed",
|
||||
error=str(e)
|
||||
)
|
||||
action = 'enable' if enabled else 'disable' if enabled is not None else 'toggle'
|
||||
return error_response(
|
||||
error.error_code,
|
||||
error.message,
|
||||
details=error.details,
|
||||
context=error.context,
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
f"Failed to {action} plugin {plugin_id or ''}".rstrip(),
|
||||
details=describe_exception(e),
|
||||
status_code=500
|
||||
)
|
||||
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
|
||||
@@ -3179,195 +3181,6 @@ sys.exit(proc.returncode)
|
||||
except Exception as e:
|
||||
logger.error('Error in execute_plugin_action', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
|
||||
def authenticate_spotify():
|
||||
"""Run Spotify authentication script"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
redirect_url = data.get('redirect_url', '').strip()
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-music'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
auth_script = Path(plugin_dir) / 'authenticate_spotify.py'
|
||||
if not auth_script.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
||||
|
||||
# Set LEDMATRIX_ROOT environment variable
|
||||
env = os.environ.copy()
|
||||
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
if redirect_url:
|
||||
# Step 2: Complete authentication with redirect URL
|
||||
# Create a wrapper script that provides the redirect URL as input
|
||||
import tempfile
|
||||
|
||||
# Create a wrapper script that provides the redirect URL
|
||||
import json
|
||||
redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
||||
wrapper.write(f'''import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# Set LEDMATRIX_ROOT
|
||||
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
||||
|
||||
# Run the auth script and provide redirect URL
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, r"{auth_script}"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=os.environ
|
||||
)
|
||||
|
||||
# Send redirect URL to stdin
|
||||
redirect_url = {redirect_url_escaped}
|
||||
stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120)
|
||||
print(stdout)
|
||||
sys.exit(proc.returncode)
|
||||
''')
|
||||
wrapper_path = wrapper.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['python3', wrapper_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env=env
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Spotify authentication completed successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Spotify authentication failed',
|
||||
'output': result.stdout + result.stderr
|
||||
}), 400
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
||||
finally:
|
||||
# The wrapper carries the user's redirect URL, so it must not
|
||||
# survive the request on any path — including a failure to
|
||||
# launch, which the previous per-branch unlinks missed.
|
||||
if os.path.exists(wrapper_path):
|
||||
os.unlink(wrapper_path)
|
||||
else:
|
||||
# Step 1: Get authorization URL
|
||||
# Import the script's functions directly to get the auth URL
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
# Load the authentication script as a module
|
||||
spec = importlib.util.spec_from_file_location("auth_spotify", auth_script)
|
||||
auth_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["auth_spotify"] = auth_module
|
||||
|
||||
# Set LEDMATRIX_ROOT before loading
|
||||
os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(auth_module)
|
||||
|
||||
# Get credentials and create OAuth object
|
||||
client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials()
|
||||
if not all([client_id, client_secret, redirect_uri]):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.'
|
||||
}), 400
|
||||
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
sp_oauth = SpotifyOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=auth_module.SCOPE,
|
||||
cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH,
|
||||
open_browser=False
|
||||
)
|
||||
|
||||
auth_url = sp_oauth.get_authorize_url()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Authorization URL generated',
|
||||
'auth_url': auth_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting Spotify auth URL", exc_info=True)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_spotify', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
|
||||
def authenticate_ytm():
|
||||
"""Run YouTube Music authentication script"""
|
||||
try:
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-music'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
auth_script = Path(plugin_dir) / 'authenticate_ytm.py'
|
||||
if not auth_script.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
|
||||
|
||||
# Set LEDMATRIX_ROOT environment variable
|
||||
env = os.environ.copy()
|
||||
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
|
||||
|
||||
# Run the authentication script
|
||||
result = subprocess.run(
|
||||
['python3', str(auth_script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'YouTube Music authentication completed successfully',
|
||||
'output': result.stdout
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'YouTube Music authentication failed',
|
||||
'output': result.stdout + result.stderr
|
||||
}), 400
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
|
||||
except Exception as e:
|
||||
logger.error('Error in authenticate_ytm', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
def _plugin_uploads_dir(plugin_id):
|
||||
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
|
||||
|
||||
@@ -3525,210 +3338,6 @@ def upload_plugin_asset():
|
||||
'total_files': len(metadata)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
|
||||
def upload_of_the_day_json():
|
||||
"""Upload JSON files for of-the-day plugin"""
|
||||
try:
|
||||
if 'files' not in request.files:
|
||||
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
||||
|
||||
files = request.files.getlist('files')
|
||||
if not files or all(not f.filename for f in files):
|
||||
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-of-the-day'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
# Setup of_the_day directory
|
||||
data_dir = Path(plugin_dir) / 'of_the_day'
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
uploaded_files = []
|
||||
max_size_per_file = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
for file in files:
|
||||
if not file.filename:
|
||||
continue
|
||||
|
||||
# Validate file extension
|
||||
if not file.filename.lower().endswith('.json'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} must be a JSON file (.json)'
|
||||
}), 400
|
||||
|
||||
# Read and validate file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > max_size_per_file:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} exceeds 5MB limit'
|
||||
}), 400
|
||||
|
||||
# Read and validate JSON content
|
||||
try:
|
||||
file_content = file.read().decode('utf-8')
|
||||
json_data = json.loads(file_content)
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON in request body'
|
||||
}), 400
|
||||
except UnicodeDecodeError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'File {file.filename} is not valid UTF-8 text'
|
||||
}), 400
|
||||
|
||||
# Validate JSON structure (must be object with day number keys)
|
||||
if not isinstance(json_data, dict):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys'
|
||||
}), 400
|
||||
|
||||
# Check if keys are valid day numbers
|
||||
for key in json_data.keys():
|
||||
try:
|
||||
day_num = int(key)
|
||||
if day_num < 1 or day_num > 365:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)'
|
||||
}), 400
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)'
|
||||
}), 400
|
||||
|
||||
# Generate safe filename from original (preserve user's filename)
|
||||
original_filename = file.filename
|
||||
safe_filename = original_filename.lower().replace(' ', '_')
|
||||
# Ensure it's a valid filename
|
||||
safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-')
|
||||
if not safe_filename.endswith('.json'):
|
||||
safe_filename += '.json'
|
||||
|
||||
file_path = data_dir / safe_filename
|
||||
|
||||
# If file exists, add counter
|
||||
counter = 1
|
||||
base_name = safe_filename.replace('.json', '')
|
||||
while file_path.exists():
|
||||
safe_filename = f"{base_name}_{counter}.json"
|
||||
file_path = data_dir / safe_filename
|
||||
counter += 1
|
||||
|
||||
# Save file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Make file readable
|
||||
os.chmod(file_path, 0o644)
|
||||
|
||||
# Extract category name from filename (remove .json extension)
|
||||
category_name = safe_filename.replace('.json', '')
|
||||
display_name = category_name.replace('_', ' ').title()
|
||||
|
||||
# Update plugin config to add category
|
||||
try:
|
||||
sys.path.insert(0, str(plugin_dir))
|
||||
from scripts.update_config import add_category_to_config
|
||||
add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name)
|
||||
except Exception as e:
|
||||
logger.warning("Could not update config: %s", e)
|
||||
# Continue anyway - file is uploaded
|
||||
|
||||
# Generate file ID (use category name as ID for simplicity)
|
||||
file_id = category_name
|
||||
|
||||
uploaded_files.append({
|
||||
'id': file_id,
|
||||
'filename': safe_filename,
|
||||
'original_filename': original_filename,
|
||||
'path': f'of_the_day/{safe_filename}',
|
||||
'size': file_size,
|
||||
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
|
||||
'category_name': category_name,
|
||||
'display_name': display_name,
|
||||
'entry_count': len(json_data)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'uploaded_files': uploaded_files,
|
||||
'total_files': len(uploaded_files)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
|
||||
def delete_of_the_day_json():
|
||||
"""Delete a JSON file from of-the-day plugin"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
file_id = data.get('file_id') # This is the category_name
|
||||
|
||||
if not file_id:
|
||||
return jsonify({'status': 'error', 'message': 'file_id is required'}), 400
|
||||
|
||||
# file_id names a file that is about to be unlinked, and it arrives
|
||||
# straight from the request body. Unvalidated, a file_id of
|
||||
# "../../../../etc/cron" made this endpoint delete any .json file on
|
||||
# the device the service could write to.
|
||||
safe_file_id = safe_path_component(file_id)
|
||||
if not safe_file_id:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
||||
|
||||
# Get plugin directory
|
||||
plugin_id = 'ledmatrix-of-the-day'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
else:
|
||||
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
|
||||
if not plugin_dir or not Path(plugin_dir).exists():
|
||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||
|
||||
filename = f"{safe_file_id}.json"
|
||||
file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename)
|
||||
if file_path is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404
|
||||
|
||||
# Delete file
|
||||
file_path.unlink()
|
||||
|
||||
# Update config to remove category
|
||||
try:
|
||||
sys.path.insert(0, str(plugin_dir))
|
||||
from scripts.update_config import remove_category_from_config
|
||||
remove_category_from_config(safe_file_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not update config: %s", e)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'File {filename} deleted successfully'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
|
||||
from flask import Blueprint, Response, render_template, jsonify, url_for
|
||||
from jinja2 import TemplateNotFound
|
||||
from markupsafe import escape
|
||||
from html.parser import HTMLParser
|
||||
@@ -20,12 +20,8 @@ from web_interface import widget_bundle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Will be initialized when blueprint is registered
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
plugin_store_manager = None
|
||||
schema_manager = None
|
||||
|
||||
# The managers live on the blueprint object: app.py sets
|
||||
# pages_v3.config_manager, pages_v3.plugin_manager and the rest.
|
||||
pages_v3 = Blueprint('pages_v3', __name__)
|
||||
|
||||
|
||||
@@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
|
||||
|
||||
@pages_v3.route('/')
|
||||
def index():
|
||||
"""Main v3 interface page"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
# Load configuration data
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
schedule_config = main_config.get('schedule', {})
|
||||
|
||||
# Get raw config files for JSON editor
|
||||
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
||||
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
|
||||
main_config_json = json.dumps(main_config_data, indent=4)
|
||||
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
||||
else:
|
||||
raise Exception("Config manager not initialized")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error loading configuration: {e}", "error")
|
||||
schedule_config = {}
|
||||
main_config_json = "{}"
|
||||
secrets_config_json = "{}"
|
||||
main_config_data = {}
|
||||
secrets_config_data = {}
|
||||
|
||||
return render_template('v3/index.html',
|
||||
schedule_config=schedule_config,
|
||||
main_config_json=main_config_json,
|
||||
secrets_config_json=secrets_config_json,
|
||||
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
|
||||
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
|
||||
main_config=main_config_data,
|
||||
secrets_config=secrets_config_data)
|
||||
"""Main v3 interface page: the app shell. Every tab loads as a partial."""
|
||||
return render_template('v3/base.html')
|
||||
|
||||
@pages_v3.route('/partials/<partial_name>')
|
||||
def load_partial(partial_name):
|
||||
@@ -552,14 +519,39 @@ def _load_display_partial():
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _plugin_default_duration(plugin_id, plugin_config):
|
||||
"""Seconds a plugin shows each screen when the Rotation page sets none.
|
||||
|
||||
Mirrors BasePlugin.get_display_duration's config fallback: the plugin's
|
||||
display_duration, else its schema default, else 15.
|
||||
"""
|
||||
def _valid(v):
|
||||
return isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0
|
||||
|
||||
value = plugin_config.get('display_duration')
|
||||
if not _valid(value):
|
||||
schema = None
|
||||
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
||||
if schema_mgr is not None:
|
||||
try:
|
||||
schema = schema_mgr.load_schema(plugin_id)
|
||||
except Exception:
|
||||
logger.debug("durations: no schema for %s", plugin_id, exc_info=True)
|
||||
value = plugin_config_defaults(schema if isinstance(schema, dict) else None).get(
|
||||
'display_duration')
|
||||
if not _valid(value):
|
||||
value = 15
|
||||
return int(value) if float(value).is_integer() else value
|
||||
|
||||
|
||||
def _load_durations_partial():
|
||||
"""Load rotation & durations partial.
|
||||
|
||||
Builds one duration entry per display mode of every enabled plugin
|
||||
(falling back to the display controller's 30s default), overlaid with any
|
||||
values saved in display.display_durations. Historically the template only
|
||||
looped over saved keys, and nothing ever populated them, so the page
|
||||
rendered empty.
|
||||
Builds one duration entry per display mode of every enabled plugin. A mode
|
||||
with a value saved in display.display_durations shows it; the rest are
|
||||
blank, with the plugin's own duration as the placeholder, because a saved
|
||||
value overrides the plugin (see DisplayController._get_display_duration).
|
||||
Pre-filling every mode would pin them all on the first save.
|
||||
"""
|
||||
try:
|
||||
if pages_v3.config_manager:
|
||||
@@ -578,10 +570,12 @@ def _load_durations_partial():
|
||||
continue
|
||||
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
|
||||
covered_keys.update(modes)
|
||||
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
|
||||
duration_groups.append({
|
||||
'plugin_id': pid,
|
||||
'plugin_name': info.get('name') or pid,
|
||||
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
|
||||
'modes': [{'key': m, 'value': saved.get(m, ''), 'default': default}
|
||||
for m in modes],
|
||||
})
|
||||
# Saved keys not owned by any enabled plugin (disabled or
|
||||
# uninstalled plugins) stay visible rather than vanishing.
|
||||
|
||||
Reference in New Issue
Block a user