Merge origin/main into fix/starlark-apps-dir-ownership

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 16:30:26 -04:00
co-authored by Claude Opus 5.5
255 changed files with 5655 additions and 24206 deletions
+5 -20
View File
@@ -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.
+2 -3
View File
@@ -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):
+18 -5
View File
@@ -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:
+100 -33
View File
@@ -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)
+12 -403
View File
@@ -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
+38 -44
View File
@@ -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.