Files
encoderPro/dashboard.py
2026-01-24 17:43:28 -05:00

1468 lines
51 KiB
Python

#!/usr/bin/env python3
"""
encoderPro Web Dashboard
========================
Modern web interface for monitoring and controlling encoderPro.
Features:
- Real-time statistics and progress
- File browser and search
- Job control (start/stop/pause)
- Encoder configuration
- Quality checking
- Log viewer
- System health monitoring
"""
import json
import logging
import os
import sqlite3
import subprocess
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
# Import encoder detection from reencode module
try:
from reencode import EncoderDetector, EncoderCapabilities
REENCODE_AVAILABLE = True
except ImportError:
REENCODE_AVAILABLE = False
logging.warning("reencode module not available for encoder detection")
try:
import yaml
except ImportError:
logging.warning("PyYAML not installed. Install with: pip install pyyaml")
yaml = None
import secrets
from flask import Flask, render_template, jsonify, request, send_from_directory, session
from flask_cors import CORS
__version__ = "3.1.0"
# =============================================================================
# CONFIGURATION
# =============================================================================
class DashboardConfig:
"""Dashboard configuration"""
def __init__(self):
# Resolve and validate paths to prevent traversal attacks
self.state_db = self._validate_path(os.getenv('STATE_DB', '/db/state.db'))
self.log_dir = self._validate_path(os.getenv('LOG_DIR', '/logs'), must_be_dir=True)
self.config_file = self._validate_path(os.getenv('CONFIG_FILE', '/config/config.yaml'))
self.reencode_script = self._validate_path(os.getenv('REENCODE_SCRIPT', '/app/reencode.py'))
self.host = os.getenv('DASHBOARD_HOST', '0.0.0.0')
self.port = int(os.getenv('DASHBOARD_PORT', '5000'))
self.debug = os.getenv('DASHBOARD_DEBUG', 'false').lower() == 'true'
if self.debug:
logging.warning("⚠️ DEBUG MODE ENABLED - Do not use in production!")
def _validate_path(self, path_str: str, must_be_dir: bool = False) -> Path:
"""Validate and resolve path to prevent traversal attacks"""
try:
path = Path(path_str).resolve()
# Security check: Ensure path doesn't escape expected directories
# In Docker, all app paths should be under /app, /db, /logs, /config, etc.
# On Windows for development, allow paths under C:\Users
import platform
allowed_prefixes = ['/app', '/db', '/logs', '/config', '/work', '/movies', '/archive']
if platform.system() == 'Windows':
# On Windows, allow local development paths
allowed_prefixes.extend([
'C:\\Users',
'C:/Users'
])
if not any(str(path).startswith(prefix) for prefix in allowed_prefixes):
raise ValueError(f"Path {path} is outside allowed directories")
return path
except Exception as e:
logging.error(f"Invalid path configuration: {path_str} - {e}")
raise ValueError(f"Invalid path: {path_str}")
config = DashboardConfig()
app = Flask(__name__)
# Security configuration
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', secrets.token_hex(32))
app.config['SESSION_COOKIE_SECURE'] = False # Set to True only when using HTTPS
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Warn if not using secure cookies
if not app.config['SESSION_COOKIE_SECURE']:
logging.warning("⚠️ SESSION_COOKIE_SECURE is False - set to True in production with HTTPS")
# Configure CORS with stricter settings
CORS(app, origins=os.getenv('CORS_ORIGINS', '*').split(','), supports_credentials=True)
# Global state
processing_thread = None
processing_active = False
processing_pid = None # Track subprocess PID for safe termination
processing_lock = threading.Lock()
# =============================================================================
# DATABASE ACCESS
# =============================================================================
class DatabaseReader:
"""Read-only database access for dashboard"""
def __init__(self, db_path: Path):
self.db_path = db_path
self._ensure_database()
def _ensure_database(self):
"""Ensure database exists and has correct schema"""
# Always run initialization - it's safe with CREATE TABLE IF NOT EXISTS
# This ensures migrations run even if the file exists but schema is outdated
self._initialize_database()
def _initialize_database(self):
"""Initialize database with schema"""
# Create directory if needed
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Create files table
cursor.execute("""
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filepath TEXT UNIQUE NOT NULL,
relative_path TEXT NOT NULL,
state TEXT NOT NULL,
has_subtitles BOOLEAN,
original_size INTEGER,
encoded_size INTEGER,
subtitle_count INTEGER,
video_codec TEXT,
audio_codec TEXT,
audio_channels INTEGER,
width INTEGER,
height INTEGER,
duration REAL,
bitrate INTEGER,
container_format TEXT,
file_hash TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
error_message TEXT,
profile_name TEXT,
encoder_used TEXT,
encode_time_seconds REAL,
fps REAL
)
""")
# Create processing_history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS processing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
profile_name TEXT,
encoder_used TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
success BOOLEAN,
error_message TEXT,
original_size INTEGER,
encoded_size INTEGER,
encode_time_seconds REAL,
fps REAL,
FOREIGN KEY (file_id) REFERENCES files (id)
)
""")
# Create indices (core columns only)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_state ON files(state)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_filepath ON files(filepath)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_profile ON files(profile_name)")
# Migration: Add new columns if they don't exist
cursor.execute("PRAGMA table_info(files)")
columns = {row[1] for row in cursor.fetchall()}
migrations = [
("video_codec", "ALTER TABLE files ADD COLUMN video_codec TEXT"),
("audio_codec", "ALTER TABLE files ADD COLUMN audio_codec TEXT"),
("audio_channels", "ALTER TABLE files ADD COLUMN audio_channels INTEGER"),
("width", "ALTER TABLE files ADD COLUMN width INTEGER"),
("height", "ALTER TABLE files ADD COLUMN height INTEGER"),
("duration", "ALTER TABLE files ADD COLUMN duration REAL"),
("bitrate", "ALTER TABLE files ADD COLUMN bitrate INTEGER"),
("container_format", "ALTER TABLE files ADD COLUMN container_format TEXT"),
("file_hash", "ALTER TABLE files ADD COLUMN file_hash TEXT"),
]
for column_name, alter_sql in migrations:
if column_name not in columns:
logging.info(f"Adding column '{column_name}' to files table")
cursor.execute(alter_sql)
# Create indices for migrated columns
cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_hash ON files(file_hash)")
conn.commit()
conn.close()
logging.info(f"✅ Database initialized at {self.db_path}")
def _get_connection(self):
"""Get database connection"""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def cleanup_stuck_processing(self):
"""Mark files stuck in 'processing' state as failed for retry"""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Find files stuck in processing state
cursor.execute("SELECT COUNT(*) as count FROM files WHERE state = 'processing'")
stuck_count = cursor.fetchone()['count']
if stuck_count > 0:
logging.warning(f"Found {stuck_count} file(s) stuck in 'processing' state from previous session")
# Mark them as failed (interrupted) so they can be retried
cursor.execute("""
UPDATE files
SET state = 'failed',
error_message = 'Processing interrupted (application restart or crash)',
completed_at = CURRENT_TIMESTAMP
WHERE state = 'processing'
""")
conn.commit()
logging.info(f"✅ Marked {stuck_count} stuck file(s) as failed for retry")
conn.close()
except Exception as e:
logging.error(f"Error cleaning up stuck processing files: {e}", exc_info=True)
def get_statistics(self) -> Dict:
"""Get processing statistics"""
conn = self._get_connection()
cursor = conn.cursor()
stats = {}
# Count by state
cursor.execute("""
SELECT state, COUNT(*) as count
FROM files
GROUP BY state
""")
for row in cursor.fetchall():
stats[row['state']] = row['count']
# Default values
for state in ['pending', 'processing', 'completed', 'failed', 'skipped']:
if state not in stats:
stats[state] = 0
# Size statistics
cursor.execute("""
SELECT
SUM(original_size) as original_total,
SUM(encoded_size) as encoded_total,
AVG(fps) as avg_fps,
AVG(encode_time_seconds) as avg_time
FROM files
WHERE state = 'completed'
""")
row = cursor.fetchone()
stats['original_size'] = row['original_total'] or 0
stats['encoded_size'] = row['encoded_total'] or 0
stats['avg_fps'] = round(row['avg_fps'] or 0, 2)
stats['avg_encode_time'] = round(row['avg_time'] or 0, 1)
# Calculate savings
if stats['original_size'] > 0:
savings = stats['original_size'] - stats['encoded_size']
stats['space_saved'] = savings
stats['space_saved_percent'] = round((savings / stats['original_size']) * 100, 1)
else:
stats['space_saved'] = 0
stats['space_saved_percent'] = 0
# Encoder usage
cursor.execute("""
SELECT encoder_used, COUNT(*) as count
FROM files
WHERE state = 'completed' AND encoder_used IS NOT NULL
GROUP BY encoder_used
""")
stats['encoder_usage'] = {row['encoder_used']: row['count'] for row in cursor.fetchall()}
# Recent activity
cursor.execute("""
SELECT COUNT(*) as count
FROM files
WHERE completed_at > datetime('now', '-24 hours')
""")
stats['completed_24h'] = cursor.fetchone()['count']
conn.close()
return stats
def get_files(self, state: Optional[str] = None, limit: int = 100,
offset: int = 0, search: Optional[str] = None,
filter_type: Optional[str] = None) -> List[Dict]:
"""Get files with filtering"""
conn = self._get_connection()
cursor = conn.cursor()
query = "SELECT * FROM files WHERE 1=1"
params = []
if state:
query += " AND state = ?"
params.append(state)
if search:
query += " AND relative_path LIKE ?"
params.append(f'%{search}%')
# Apply attribute filters
if filter_type:
if filter_type == 'has_subtitles':
query += " AND has_subtitles = 1"
elif filter_type == 'no_subtitles':
query += " AND (has_subtitles = 0 OR has_subtitles IS NULL)"
elif filter_type == 'large_files':
# Files larger than 5GB
query += " AND original_size > 5368709120"
elif filter_type == 'surround_sound':
# 5.1 or 7.1 audio (6+ channels)
query += " AND audio_channels >= 6"
elif filter_type == 'stereo_only':
# Stereo or mono (< 6 channels)
query += " AND audio_channels < 6"
elif filter_type == '4k':
# 4K resolution (3840x2160 or higher)
query += " AND width >= 3840"
elif filter_type == '1080p':
# 1080p resolution
query += " AND width >= 1920 AND width < 3840 AND height >= 1080"
elif filter_type == '720p':
# 720p resolution
query += " AND width >= 1280 AND width < 1920"
elif filter_type == 'h264':
# H.264/AVC codec
query += " AND video_codec LIKE '%264%'"
elif filter_type == 'h265':
# H.265/HEVC codec
query += " AND video_codec LIKE '%265%' OR video_codec LIKE '%hevc%'"
elif filter_type == 'high_bitrate':
# High bitrate (> 10 Mbps)
query += " AND bitrate > 10000000"
query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
files = [dict(row) for row in cursor.fetchall()]
conn.close()
return files
def get_file(self, file_id: int) -> Optional[Dict]:
"""Get single file by ID"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM files WHERE id = ?", (file_id,))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
def get_recent_activity(self, limit: int = 20) -> List[Dict]:
"""Get recent file activity"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, relative_path, state, updated_at, encoder_used, fps
FROM files
WHERE state IN ('completed', 'failed')
ORDER BY updated_at DESC
LIMIT ?
""", (limit,))
activity = [dict(row) for row in cursor.fetchall()]
conn.close()
return activity
def get_processing_files(self) -> List[Dict]:
"""Get currently processing files"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, relative_path, started_at, profile_name
FROM files
WHERE state = 'processing'
ORDER BY started_at
""")
files = [dict(row) for row in cursor.fetchall()]
conn.close()
return files
def process_duplicates(self) -> Dict:
"""
Process existing database to find and mark duplicates.
Returns statistics about duplicates found.
"""
import hashlib
from pathlib import Path
conn = self._get_connection()
cursor = conn.cursor()
# Get all files that don't have a hash yet or aren't already marked as duplicates
cursor.execute("""
SELECT id, filepath, file_hash, state, relative_path
FROM files
WHERE state != 'skipped' OR (state = 'skipped' AND error_message NOT LIKE 'Duplicate of:%')
ORDER BY id
""")
files = [dict(row) for row in cursor.fetchall()]
stats = {
'total_files': len(files),
'files_hashed': 0,
'duplicates_found': 0,
'duplicates_marked': 0,
'errors': 0
}
# Track hashes we've seen
hash_to_file = {} # hash -> (id, filepath, state)
for file in files:
file_path = Path(file['filepath'])
file_hash = file['file_hash']
# Calculate hash if missing
if not file_hash:
if not file_path.exists():
stats['errors'] += 1
continue
try:
# Use the same hashing logic as MediaInspector
file_hash = self._calculate_file_hash(file_path)
if file_hash:
# Update file with hash
cursor.execute("""
UPDATE files SET file_hash = ? WHERE id = ?
""", (file_hash, file['id']))
stats['files_hashed'] += 1
except Exception as e:
logging.error(f"Failed to hash {file_path}: {e}")
stats['errors'] += 1
continue
# Check if this hash has been seen before
if file_hash in hash_to_file:
original = hash_to_file[file_hash]
# Only mark as duplicate if original is completed
if original['state'] == 'completed':
stats['duplicates_found'] += 1
# Mark current file as skipped duplicate
cursor.execute("""
UPDATE files
SET state = 'skipped',
error_message = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (f"Duplicate of: {original['relative_path']}", file['id']))
stats['duplicates_marked'] += 1
logging.info(f"Marked duplicate: {file['relative_path']} -> {original['relative_path']}")
else:
# First time seeing this hash
hash_to_file[file_hash] = {
'id': file['id'],
'filepath': file['filepath'],
'relative_path': file['relative_path'],
'state': file['state']
}
conn.commit()
conn.close()
return stats
def _calculate_file_hash(self, filepath: Path, chunk_size: int = 8192) -> str:
"""Calculate file hash using same logic as MediaInspector"""
import hashlib
try:
file_size = filepath.stat().st_size
# For small files (<100MB), hash the entire file
if file_size < 100 * 1024 * 1024:
hasher = hashlib.sha256()
with open(filepath, 'rb') as f:
while chunk := f.read(chunk_size):
hasher.update(chunk)
return hasher.hexdigest()
# For large files, hash: size + first 64KB + middle 64KB + last 64KB
hasher = hashlib.sha256()
hasher.update(str(file_size).encode())
with open(filepath, 'rb') as f:
# First chunk
hasher.update(f.read(65536))
# Middle chunk
f.seek(file_size // 2)
hasher.update(f.read(65536))
# Last chunk
f.seek(-65536, 2)
hasher.update(f.read(65536))
return hasher.hexdigest()
except Exception as e:
logging.error(f"Failed to hash file {filepath}: {e}")
return None
# =============================================================================
# SYSTEM MONITORING
# =============================================================================
class SystemMonitor:
"""Monitor system resources"""
@staticmethod
def get_gpu_stats() -> List[Dict]:
"""Get GPU statistics"""
try:
result = subprocess.run(
['nvidia-smi', '--query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu',
'--format=csv,noheader,nounits'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
gpus = []
for line in result.stdout.strip().split('\n'):
if line:
parts = [p.strip() for p in line.split(',')]
gpus.append({
'index': int(parts[0]),
'name': parts[1],
'utilization': int(parts[2]),
'memory_used': int(parts[3]),
'memory_total': int(parts[4]),
'temperature': int(parts[5])
})
return gpus
except:
pass
return []
@staticmethod
def get_cpu_stats() -> Dict:
"""Get CPU statistics"""
try:
# Load average
with open('/proc/loadavg', 'r') as f:
load = f.read().strip().split()[:3]
load_avg = [float(x) for x in load]
# CPU count
cpu_count = os.cpu_count() or 1
return {
'load_1m': load_avg[0],
'load_5m': load_avg[1],
'load_15m': load_avg[2],
'cpu_count': cpu_count,
'load_percent': round((load_avg[0] / cpu_count) * 100, 1)
}
except:
return {'load_1m': 0, 'load_5m': 0, 'load_15m': 0, 'cpu_count': 1, 'load_percent': 0}
@staticmethod
def get_disk_stats() -> Dict:
"""Get disk statistics"""
try:
import shutil
# Work directory
work_usage = shutil.disk_usage('/work')
return {
'work_total': work_usage.total,
'work_used': work_usage.used,
'work_free': work_usage.free,
'work_percent': round((work_usage.used / work_usage.total) * 100, 1)
}
except:
return {'work_total': 0, 'work_used': 0, 'work_free': 0, 'work_percent': 0}
# =============================================================================
# JOB CONTROL
# =============================================================================
class JobController:
"""Control encoding jobs"""
@staticmethod
def start_processing(profile: Optional[str] = None, dry_run: bool = False) -> Dict:
"""Start processing job"""
global processing_thread, processing_active
with processing_lock:
if processing_active:
return {'success': False, 'message': 'Processing already active'}
# Check if script exists
if not config.reencode_script.exists():
error_msg = f"Reencode script not found at {config.reencode_script}"
logging.error(error_msg)
return {'success': False, 'message': error_msg}
# Check if config file exists
if not config.config_file.exists():
error_msg = f"Config file not found at {config.config_file}"
logging.error(error_msg)
return {'success': False, 'message': error_msg}
cmd = ['python3', str(config.reencode_script), '-c', str(config.config_file)]
if profile:
cmd.extend(['--profile', profile])
if dry_run:
# For dry run, just do a scan
cmd.append('--scan-only')
else:
# Skip scan when processing (dashboard already selected files)
cmd.append('--no-scan')
logging.info(f"Starting processing with command: {' '.join(cmd)}")
def run_processing():
global processing_active, processing_pid
processing_active = True
try:
# Small delay to ensure database transaction is committed
import time
time.sleep(0.5)
# Start process and track PID
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
processing_pid = process.pid
logging.info(f"Started processing with PID {processing_pid}")
logging.info(f"Command: {' '.join(cmd)}")
# Wait for completion
stdout, stderr = process.communicate()
# Log output
if stdout:
logging.info(f"Processing output: {stdout}")
if stderr:
logging.error(f"Processing errors: {stderr}")
finally:
processing_active = False
processing_pid = None
processing_thread = threading.Thread(target=run_processing, daemon=True)
processing_thread.start()
mode = "Dry run started" if dry_run else "Processing started"
return {'success': True, 'message': mode, 'dry_run': dry_run}
@staticmethod
def stop_processing() -> Dict:
"""Stop processing job"""
global processing_active, processing_pid
with processing_lock:
if not processing_active:
return {'success': False, 'message': 'No active processing'}
# Send SIGTERM to reencode process using tracked PID
try:
if processing_pid:
import signal
try:
os.kill(processing_pid, signal.SIGTERM)
processing_active = False
processing_pid = None
return {'success': True, 'message': 'Processing stopped'}
except ProcessLookupError:
# Process already dead
processing_active = False
processing_pid = None
return {'success': True, 'message': 'Process already stopped'}
else:
# Fallback: process thread but no PID tracked
processing_active = False
return {'success': True, 'message': 'Processing flag cleared (no PID tracked)'}
except Exception as e:
logging.error(f"Failed to stop processing: {e}")
return {'success': False, 'message': 'Failed to stop processing'}
@staticmethod
def is_processing() -> bool:
"""Check if processing is active"""
return processing_active
# =============================================================================
# API ROUTES
# =============================================================================
db_reader = DatabaseReader(config.state_db)
system_monitor = SystemMonitor()
job_controller = JobController()
# CSRF Protection
def generate_csrf_token():
"""Generate CSRF token for session"""
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(32)
return session['csrf_token']
def validate_csrf_token():
"""Validate CSRF token from request"""
token = request.headers.get('X-CSRF-Token') or request.form.get('csrf_token')
session_token = session.get('csrf_token')
# Debug logging
if not token:
logging.warning(f"CSRF validation failed: No token in request headers or form")
elif not session_token:
logging.warning(f"CSRF validation failed: No token in session")
elif token != session_token:
logging.warning(f"CSRF validation failed: Token mismatch")
if not token or token != session_token:
return False
return True
@app.before_request
def csrf_protect():
"""CSRF protection for state-changing requests"""
if request.method in ['POST', 'PUT', 'DELETE', 'PATCH']:
# Skip CSRF for health check and csrf-token endpoint
if request.path in ['/api/health', '/api/csrf-token']:
return
if not validate_csrf_token():
return jsonify({'success': False, 'error': 'CSRF token validation failed'}), 403
# Global error handler
@app.errorhandler(Exception)
def handle_exception(e):
"""Handle all uncaught exceptions"""
logging.error(f"Unhandled exception: {e}", exc_info=True)
return jsonify({
'success': False,
'error': str(e),
'type': type(e).__name__
}), 500
@app.route('/')
def index():
"""Main dashboard page"""
csrf_token = generate_csrf_token()
return render_template('dashboard.html', csrf_token=csrf_token)
@app.route('/favicon.ico')
def favicon():
"""Return empty favicon to prevent 404 errors"""
return '', 204
@app.route('/api/csrf-token')
def get_csrf_token():
"""Get CSRF token for client"""
return jsonify({'csrf_token': generate_csrf_token()})
@app.route('/api/stats')
def api_stats():
"""Get statistics"""
try:
stats = db_reader.get_statistics()
return jsonify({'success': True, 'data': stats})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/files')
def api_files():
"""Get files list"""
try:
# Auto-cleanup stuck files whenever file list is requested
# This ensures stuck files are cleaned up even if startup cleanup failed
if not processing_active:
db_reader.cleanup_stuck_processing()
state = request.args.get('state')
# Validate state
valid_states = ['discovered', 'pending', 'processing', 'completed', 'failed', 'skipped', None]
if state and state not in valid_states:
return jsonify({'success': False, 'error': 'Invalid state parameter'}), 400
# Validate and limit pagination parameters
try:
limit = int(request.args.get('limit', 100))
offset = int(request.args.get('offset', 0))
except ValueError:
return jsonify({'success': False, 'error': 'Invalid limit or offset'}), 400
if limit < 1 or limit > 1000:
return jsonify({'success': False, 'error': 'Limit must be between 1 and 1000'}), 400
if offset < 0:
return jsonify({'success': False, 'error': 'Offset must be non-negative'}), 400
# Validate and sanitize search parameter
search = request.args.get('search')
if search and len(search) > 500:
return jsonify({'success': False, 'error': 'Search query too long'}), 400
# Get filter parameter
filter_type = request.args.get('filter')
valid_filters = [
'has_subtitles', 'no_subtitles', 'large_files', 'surround_sound',
'stereo_only', '4k', '1080p', '720p', 'h264', 'h265', 'high_bitrate'
]
if filter_type and filter_type not in valid_filters:
return jsonify({'success': False, 'error': 'Invalid filter parameter'}), 400
files = db_reader.get_files(state, limit, offset, search, filter_type)
return jsonify({'success': True, 'data': files})
except Exception as e:
logging.error(f"Error in api_files: {e}", exc_info=True)
return jsonify({'success': False, 'error': 'Internal server error'}), 500
@app.route('/api/file/<int:file_id>')
def api_file(file_id):
"""Get single file details"""
try:
file_data = db_reader.get_file(file_id)
if file_data:
return jsonify({'success': True, 'data': file_data})
else:
return jsonify({'success': False, 'error': 'File not found'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/activity')
def api_activity():
"""Get recent activity"""
try:
limit = int(request.args.get('limit', 20))
activity = db_reader.get_recent_activity(limit)
return jsonify({'success': True, 'data': activity})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/processing')
def api_processing():
"""Get currently processing files"""
try:
files = db_reader.get_processing_files()
is_active = job_controller.is_processing()
return jsonify({
'success': True,
'data': {
'active': is_active,
'files': files
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/system')
def api_system():
"""Get system statistics"""
try:
data = {
'gpu': system_monitor.get_gpu_stats(),
'cpu': system_monitor.get_cpu_stats(),
'disk': system_monitor.get_disk_stats()
}
return jsonify({'success': True, 'data': data})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/encoders')
def api_encoders():
"""Get available encoder capabilities"""
try:
if not REENCODE_AVAILABLE:
return jsonify({
'success': False,
'error': 'Encoder detection not available'
}), 500
# Detect encoder capabilities
caps = EncoderDetector.detect_capabilities()
# Build response with hardware info
encoders = {
'cpu': {
'h264': caps.has_x264,
'h265': caps.has_x265,
'av1': caps.has_av1
},
'nvidia': {
'available': caps.has_nvenc,
'h264': caps.has_nvenc,
'h265': caps.has_nvenc,
'av1': caps.has_nvenc_av1,
'devices': caps.nvenc_devices if caps.has_nvenc else []
},
'intel': {
'available': caps.has_qsv,
'h264': caps.has_qsv,
'h265': caps.has_qsv,
'av1': caps.has_qsv_av1
},
'amd': {
'available': caps.has_vaapi,
'h264': caps.has_vaapi,
'h265': caps.has_vaapi,
'av1': caps.has_vaapi_av1
}
}
return jsonify({'success': True, 'encoders': encoders})
except Exception as e:
logging.error(f"Error detecting encoders: {e}", exc_info=True)
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/jobs/start', methods=['POST'])
def api_start_job():
"""Start processing job"""
try:
data = request.get_json() or {}
profile = data.get('profile')
dry_run = data.get('dry_run', False)
result = job_controller.start_processing(profile, dry_run)
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/jobs/stop', methods=['POST'])
def api_stop_job():
"""Stop processing job"""
try:
result = job_controller.stop_processing()
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/jobs/scan', methods=['POST'])
def api_scan_library():
"""Scan library to populate database"""
try:
global processing_thread, processing_active
with processing_lock:
if processing_active:
return jsonify({'success': False, 'message': 'Processing already active'})
cmd = ['python3', str(config.reencode_script), '-c', str(config.config_file), '--scan-only']
def run_scan():
global processing_active
processing_active = True
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.stdout:
logging.info(f"Scan output: {result.stdout}")
if result.stderr:
logging.error(f"Scan errors: {result.stderr}")
finally:
processing_active = False
processing_thread = threading.Thread(target=run_scan, daemon=True)
processing_thread.start()
return jsonify({'success': True, 'message': 'Library scan started'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/jobs/reencode-selected', methods=['POST'])
def api_reencode_selected():
"""Re-encode selected files with specified profile"""
try:
data = request.get_json()
file_ids = data.get('file_ids', [])
profile = data.get('profile')
# Validate inputs
if not file_ids:
return jsonify({'success': False, 'error': 'No files selected'}), 400
if not isinstance(file_ids, list):
return jsonify({'success': False, 'error': 'file_ids must be an array'}), 400
# Validate all file_ids are integers and limit count
if len(file_ids) > 1000:
return jsonify({'success': False, 'error': 'Too many files selected (max 1000)'}), 400
try:
file_ids = [int(fid) for fid in file_ids]
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid file IDs - must be integers'}), 400
if not profile or not isinstance(profile, str):
return jsonify({'success': False, 'error': 'No profile specified'}), 400
# Validate profile name (alphanumeric, underscore, hyphen only)
import re
if not re.match(r'^[a-zA-Z0-9_-]+$', profile):
return jsonify({'success': False, 'error': 'Invalid profile name'}), 400
# Update file states in database to pending
conn = None
try:
conn = sqlite3.connect(str(config.state_db))
cursor = conn.cursor()
placeholders = ','.join('?' * len(file_ids))
cursor.execute(f"""
UPDATE files
SET state = 'pending',
profile_name = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders})
""", [profile] + file_ids)
updated_count = cursor.rowcount
conn.commit()
logging.info(f"Reset {updated_count} files to pending state with profile {profile}")
return jsonify({
'success': True,
'message': f'{updated_count} files queued for re-encoding',
'count': updated_count
})
finally:
if conn:
conn.close()
except Exception as e:
logging.error(f"Failed to queue files for re-encoding: {e}", exc_info=True)
return jsonify({'success': False, 'error': 'Internal server error'}), 500
@app.route('/api/jobs/reset-stuck', methods=['POST'])
def api_reset_stuck():
"""Mark files stuck in processing state as failed for retry"""
try:
db_reader.cleanup_stuck_processing()
return jsonify({'success': True, 'message': 'Stuck files marked as failed'})
except Exception as e:
logging.error(f"Failed to reset stuck files: {e}", exc_info=True)
return jsonify({'success': False, 'error': 'Internal server error'}), 500
@app.route('/api/logs')
def api_logs():
"""Get recent log entries"""
try:
lines = int(request.args.get('lines', 100))
log_file = config.log_dir / 'encoderpro.log'
if log_file.exists():
with open(log_file, 'r') as f:
all_lines = f.readlines()
recent_lines = all_lines[-lines:]
return jsonify({'success': True, 'data': recent_lines})
else:
return jsonify({'success': True, 'data': []})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/health')
def api_health():
"""Health check endpoint"""
db_exists = config.state_db.exists()
db_file_count = 0
if db_exists:
try:
conn = sqlite3.connect(str(config.state_db))
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM files")
db_file_count = cursor.fetchone()[0]
conn.close()
except:
pass
return jsonify({
'success': True,
'data': {
'status': 'healthy',
'version': __version__,
'timestamp': datetime.now().isoformat(),
'database': {
'exists': db_exists,
'path': str(config.state_db),
'file_count': db_file_count,
'needs_scan': db_file_count == 0
}
}
})
@app.route('/api/config')
def api_get_config():
"""Get current configuration"""
try:
if yaml is None:
return jsonify({'success': False, 'error': 'PyYAML not installed. Run: pip install pyyaml'}), 500
if config.config_file.exists():
with open(config.config_file, 'r') as f:
config_data = yaml.safe_load(f)
return jsonify({'success': True, 'data': config_data})
else:
return jsonify({'success': False, 'error': 'Config file not found'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/config', methods=['POST'])
def api_save_config():
"""Save configuration"""
try:
if yaml is None:
return jsonify({'success': False, 'error': 'PyYAML not installed. Run: pip install pyyaml'}), 500
new_config = request.get_json()
if not new_config:
return jsonify({'success': False, 'error': 'No configuration provided'}), 400
# Validate required fields
required_fields = ['movies_dir', 'archive_dir', 'work_dir']
for field in required_fields:
if field not in new_config:
return jsonify({'success': False, 'error': f'Missing required field: {field}'}), 400
# Backup existing config
if config.config_file.exists():
backup_path = config.config_file.parent / f"{config.config_file.name}.backup"
import shutil
shutil.copy(config.config_file, backup_path)
# Save new config
with open(config.config_file, 'w') as f:
yaml.dump(new_config, f, default_flow_style=False)
return jsonify({
'success': True,
'message': 'Configuration saved successfully',
'data': new_config
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/config/validate', methods=['POST'])
def api_validate_config():
"""Validate configuration without saving"""
try:
config_data = request.get_json()
if not config_data:
return jsonify({'success': False, 'error': 'No configuration provided'}), 400
errors = []
warnings = []
# Check required fields
required_fields = ['movies_dir', 'archive_dir', 'work_dir']
for field in required_fields:
if field not in config_data:
errors.append(f'Missing required field: {field}')
# Check if directories exist
from pathlib import Path
if 'movies_dir' in config_data:
movies_path = Path(config_data['movies_dir'])
if not movies_path.exists():
warnings.append(f"Movies directory does not exist: {movies_path}")
elif not movies_path.is_dir():
errors.append(f"Movies path is not a directory: {movies_path}")
if 'archive_dir' in config_data:
archive_path = Path(config_data['archive_dir'])
if not archive_path.exists():
warnings.append(f"Archive directory does not exist (will be created): {archive_path}")
# Check parallel settings
if 'parallel' in config_data:
parallel = config_data['parallel']
max_workers = parallel.get('max_workers', 1)
gpu_slots = parallel.get('gpu_slots', 0)
if max_workers < 1:
errors.append("max_workers must be at least 1")
if max_workers > 10:
warnings.append(f"max_workers={max_workers} is very high, may cause system instability")
if gpu_slots > max_workers:
warnings.append("gpu_slots should not exceed max_workers")
# Check profiles
if 'profiles' in config_data:
profiles = config_data.get('profiles', {})
if 'definitions' in profiles:
for profile_name, profile_data in profiles['definitions'].items():
if 'encoder' not in profile_data:
errors.append(f"Profile '{profile_name}' missing encoder")
if 'quality' not in profile_data:
warnings.append(f"Profile '{profile_name}' missing quality setting")
is_valid = len(errors) == 0
return jsonify({
'success': True,
'data': {
'valid': is_valid,
'errors': errors,
'warnings': warnings
}
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles')
def api_get_profiles():
"""Get available encoding profiles"""
try:
if yaml is None:
return jsonify({'success': False, 'error': 'PyYAML not installed. Run: pip install pyyaml'}), 500
if config.config_file.exists():
with open(config.config_file, 'r') as f:
config_data = yaml.safe_load(f)
profiles = config_data.get('profiles', {})
return jsonify({
'success': True,
'data': {
'default': profiles.get('default', 'balanced_gpu'),
'profiles': profiles.get('definitions', {})
}
})
else:
return jsonify({'success': False, 'error': 'Config file not found'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/encoders')
def api_get_encoders():
"""Get available encoders on the system"""
try:
# Check FFmpeg encoders
result = subprocess.run(
['ffmpeg', '-hide_banner', '-encoders'],
capture_output=True,
text=True,
timeout=10
)
encoders_output = result.stdout.lower()
available = {
'cpu': {
'x265': 'libx265' in encoders_output,
'x264': 'libx264' in encoders_output
},
'nvidia': {
'nvenc_h265': 'hevc_nvenc' in encoders_output,
'nvenc_h264': 'h264_nvenc' in encoders_output
},
'intel': {
'qsv_h265': 'hevc_qsv' in encoders_output,
'qsv_h264': 'h264_qsv' in encoders_output
},
'amd': {
'vaapi_h265': 'hevc_vaapi' in encoders_output,
'vaapi_h264': 'h264_vaapi' in encoders_output
}
}
return jsonify({'success': True, 'data': available})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/directories/validate', methods=['POST'])
def api_validate_directories():
"""Validate directory paths"""
try:
data = request.get_json()
paths_to_check = data.get('paths', {})
results = {}
for name, path_str in paths_to_check.items():
from pathlib import Path
path = Path(path_str)
results[name] = {
'path': path_str,
'exists': path.exists(),
'is_directory': path.is_dir() if path.exists() else False,
'is_writable': os.access(path, os.W_OK) if path.exists() else False,
'is_readable': os.access(path, os.R_OK) if path.exists() else False
}
return jsonify({'success': True, 'data': results})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# UTILITY FUNCTIONS
# =============================================================================
def format_bytes(bytes_val: int) -> str:
"""Format bytes to human readable"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_val < 1024.0:
return f"{bytes_val:.2f} {unit}"
bytes_val /= 1024.0
return f"{bytes_val:.2f} PB"
def format_duration(seconds: float) -> str:
"""Format seconds to human readable duration"""
if seconds < 60:
return f"{seconds:.0f}s"
elif seconds < 3600:
return f"{seconds/60:.0f}m"
else:
hours = seconds / 3600
return f"{hours:.1f}h"
# Register template filters
app.jinja_env.filters['format_bytes'] = format_bytes
app.jinja_env.filters['format_duration'] = format_duration
# =============================================================================
# MAIN
# =============================================================================
def main():
"""Run dashboard server"""
# Set log level based on debug mode
log_level = logging.DEBUG if config.debug else logging.INFO
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
logger.info(f"Starting Web Dashboard v{__version__}")
logger.info(f"Server: http://{config.host}:{config.port}")
logger.info(f"Database: {config.state_db}")
logger.info(f"Config: {config.config_file}")
logger.info(f"Debug mode: {config.debug}")
logger.info(f"Log level: {logging.getLevelName(log_level)}")
# Clean up any files stuck in processing state from previous session
try:
logger.info("Checking for files stuck in processing state...")
db_reader.cleanup_stuck_processing()
except Exception as e:
logger.error(f"Failed to cleanup stuck files on startup: {e}", exc_info=True)
@app.route('/api/process-duplicates', methods=['POST'])
def api_process_duplicates():
"""Process database to find and mark duplicates"""
try:
logging.info("Starting duplicate processing...")
stats = db_reader.process_duplicates()
logging.info(f"Duplicate processing complete: {stats}")
return jsonify({'success': True, 'stats': stats})
except Exception as e:
logging.error(f"Error processing duplicates: {e}", exc_info=True)
return jsonify({'success': False, 'error': str(e)}), 500
def main():
"""Main entry point"""
app.run(
host=config.host,
port=config.port,
debug=config.debug,
threaded=True
)
if __name__ == '__main__':
main()