1302 lines
42 KiB
Python
1302 lines
42 KiB
Python
import os
|
|
import sys
|
|
import threading
|
|
from datetime import datetime
|
|
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
|
from flask_socketio import SocketIO, emit
|
|
import logging
|
|
import atexit
|
|
|
|
# Add the parent directory to sys.path to import our modules
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
from main import SeriesApp
|
|
from core.entities.series import Serie
|
|
from core.entities import SerieList
|
|
from infrastructure.file_system import SerieScanner
|
|
from infrastructure.providers.provider_factory import Loaders
|
|
from web.controllers.auth_controller import session_manager, require_auth, optional_auth
|
|
from config import config
|
|
from application.services.queue_service import download_queue_bp
|
|
|
|
# Simple decorator to replace handle_api_errors
|
|
def handle_api_errors(f):
|
|
"""Simple error handling decorator."""
|
|
from functools import wraps
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
try:
|
|
return f(*args, **kwargs)
|
|
except Exception as e:
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
return decorated_function
|
|
|
|
# Create placeholder managers for missing modules
|
|
class PlaceholderManager:
|
|
"""Placeholder manager for missing UX modules."""
|
|
def get_shortcuts_js(self): return ""
|
|
def get_drag_drop_js(self): return ""
|
|
def get_bulk_operations_js(self): return ""
|
|
def get_preferences_js(self): return ""
|
|
def get_search_js(self): return ""
|
|
def get_undo_redo_js(self): return ""
|
|
def get_mobile_responsive_js(self): return ""
|
|
def get_touch_gesture_js(self): return ""
|
|
def get_accessibility_js(self): return ""
|
|
def get_screen_reader_js(self): return ""
|
|
def get_contrast_js(self): return ""
|
|
def get_multiscreen_js(self): return ""
|
|
def get_css(self): return ""
|
|
def get_contrast_css(self): return ""
|
|
def get_multiscreen_css(self): return ""
|
|
|
|
# Create placeholder instances
|
|
keyboard_manager = PlaceholderManager()
|
|
drag_drop_manager = PlaceholderManager()
|
|
bulk_operations_manager = PlaceholderManager()
|
|
preferences_manager = PlaceholderManager()
|
|
advanced_search_manager = PlaceholderManager()
|
|
undo_redo_manager = PlaceholderManager()
|
|
mobile_responsive_manager = PlaceholderManager()
|
|
touch_gesture_manager = PlaceholderManager()
|
|
accessibility_manager = PlaceholderManager()
|
|
screen_reader_manager = PlaceholderManager()
|
|
color_contrast_manager = PlaceholderManager()
|
|
multi_screen_manager = PlaceholderManager()
|
|
|
|
# Placeholder process lock constants and functions
|
|
RESCAN_LOCK = "rescan"
|
|
DOWNLOAD_LOCK = "download"
|
|
CLEANUP_LOCK = "cleanup"
|
|
|
|
# Simple in-memory process lock system
|
|
_active_locks = {}
|
|
|
|
def is_process_running(lock_name):
|
|
"""Check if a process is currently running (locked)."""
|
|
return lock_name in _active_locks
|
|
|
|
def acquire_lock(lock_name, locked_by="system"):
|
|
"""Acquire a process lock."""
|
|
if lock_name in _active_locks:
|
|
raise ProcessLockError(f"Process {lock_name} is already running")
|
|
_active_locks[lock_name] = {
|
|
'locked_by': locked_by,
|
|
'timestamp': datetime.now()
|
|
}
|
|
|
|
def release_lock(lock_name):
|
|
"""Release a process lock."""
|
|
if lock_name in _active_locks:
|
|
del _active_locks[lock_name]
|
|
|
|
def with_process_lock(lock_name, timeout_minutes=30):
|
|
"""Decorator for process locking."""
|
|
def decorator(f):
|
|
from functools import wraps
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
# Extract locked_by from kwargs if provided
|
|
locked_by = kwargs.pop('_locked_by', 'system')
|
|
|
|
try:
|
|
acquire_lock(lock_name, locked_by)
|
|
return f(*args, **kwargs)
|
|
finally:
|
|
release_lock(lock_name)
|
|
return decorated_function
|
|
return decorator
|
|
|
|
class ProcessLockError(Exception):
|
|
"""Placeholder exception for process lock errors."""
|
|
pass
|
|
|
|
class RetryableError(Exception):
|
|
"""Placeholder exception for retryable errors."""
|
|
pass
|
|
|
|
# Placeholder objects for missing modules
|
|
class PlaceholderNetworkChecker:
|
|
def get_network_status(self): return {"status": "unknown"}
|
|
def check_url_reachability(self, url): return False
|
|
|
|
class PlaceholderErrorManager:
|
|
def __init__(self):
|
|
self.error_history = []
|
|
self.blacklisted_urls = {}
|
|
self.retry_counts = {}
|
|
|
|
class PlaceholderHealthMonitor:
|
|
def get_current_health_status(self): return {"status": "unknown"}
|
|
|
|
network_health_checker = PlaceholderNetworkChecker()
|
|
error_recovery_manager = PlaceholderErrorManager()
|
|
health_monitor = PlaceholderHealthMonitor()
|
|
|
|
def check_process_locks():
|
|
"""Placeholder function for process lock checking."""
|
|
pass
|
|
|
|
# TODO: Fix these imports
|
|
# from process_api import process_bp
|
|
# from scheduler_api import scheduler_bp
|
|
# from logging_api import logging_bp
|
|
# from config_api import config_bp
|
|
# from scheduler import init_scheduler, get_scheduler
|
|
# from process_locks import (with_process_lock, RESCAN_LOCK, DOWNLOAD_LOCK,
|
|
# ProcessLockError, is_process_running, check_process_locks)
|
|
|
|
# TODO: Fix these imports
|
|
# # Import new error handling and health monitoring modules
|
|
# from error_handler import (
|
|
# handle_api_errors, error_recovery_manager, recovery_strategies,
|
|
# network_health_checker, NetworkError, DownloadError, RetryableError
|
|
# )
|
|
# from health_monitor import health_bp, health_monitor, init_health_monitoring, cleanup_health_monitoring
|
|
|
|
# TODO: Fix these imports
|
|
# # Import performance optimization modules
|
|
# from performance_optimizer import (
|
|
# init_performance_monitoring, cleanup_performance_monitoring,
|
|
# speed_limiter, download_cache, memory_monitor, download_manager
|
|
# )
|
|
# from performance_api import performance_bp
|
|
|
|
# TODO: Fix these imports
|
|
# # Import API integration modules
|
|
# from api_integration import (
|
|
# init_api_integrations, cleanup_api_integrations,
|
|
# webhook_manager, export_manager, notification_service
|
|
# )
|
|
# from api_endpoints import api_integration_bp
|
|
#
|
|
# # Import database management modules
|
|
# from database_manager import (
|
|
# database_manager, anime_repository, backup_manager, storage_manager,
|
|
# init_database_system, cleanup_database_system
|
|
# )
|
|
# from database_api import database_bp
|
|
#
|
|
# # Import health check endpoints
|
|
# from health_endpoints import health_bp
|
|
#
|
|
# # Import user experience modules
|
|
# from keyboard_shortcuts import keyboard_manager
|
|
# from drag_drop import drag_drop_manager
|
|
# from bulk_operations import bulk_operations_manager
|
|
# from user_preferences import preferences_manager, preferences_bp
|
|
# from advanced_search import advanced_search_manager, search_bp
|
|
# from undo_redo_manager import undo_redo_manager, undo_redo_bp
|
|
#
|
|
# # Import Mobile & Accessibility modules
|
|
# from mobile_responsive import mobile_responsive_manager
|
|
# from touch_gestures import touch_gesture_manager
|
|
# from accessibility_features import accessibility_manager
|
|
# from screen_reader_support import screen_reader_manager
|
|
# from color_contrast_compliance import color_contrast_manager
|
|
# from multi_screen_support import multi_screen_manager
|
|
|
|
app = Flask(__name__,
|
|
template_folder='web/templates/base',
|
|
static_folder='web/static')
|
|
app.config['SECRET_KEY'] = os.urandom(24)
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 # 24 hours
|
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
|
|
|
# Error handler for API routes to return JSON instead of HTML
|
|
@app.errorhandler(404)
|
|
def handle_api_not_found(error):
|
|
"""Handle 404 errors for API routes by returning JSON instead of HTML."""
|
|
if request.path.startswith('/api/'):
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'API endpoint not found',
|
|
'path': request.path
|
|
}), 404
|
|
# For non-API routes, let Flask handle it normally
|
|
return error
|
|
|
|
# Register essential blueprints only
|
|
app.register_blueprint(download_queue_bp)
|
|
# TODO: Fix and uncomment these blueprints when modules are available
|
|
# app.register_blueprint(process_bp)
|
|
# app.register_blueprint(scheduler_bp)
|
|
# app.register_blueprint(logging_bp)
|
|
# app.register_blueprint(config_bp)
|
|
# app.register_blueprint(health_bp)
|
|
# app.register_blueprint(performance_bp)
|
|
# app.register_blueprint(api_integration_bp)
|
|
# app.register_blueprint(database_bp)
|
|
# Note: health_endpoints blueprint already imported above as health_bp, no need to register twice
|
|
|
|
# TODO: Fix and register these APIs when modules are available
|
|
# # Register bulk operations API
|
|
# from bulk_api import bulk_api_bp
|
|
# app.register_blueprint(bulk_api_bp)
|
|
#
|
|
# # Register user preferences API
|
|
# app.register_blueprint(preferences_bp)
|
|
#
|
|
# # Register advanced search API
|
|
# app.register_blueprint(search_bp)
|
|
#
|
|
# # Register undo/redo API
|
|
# app.register_blueprint(undo_redo_bp)
|
|
#
|
|
# # Register Mobile & Accessibility APIs
|
|
# app.register_blueprint(color_contrast_manager.get_contrast_api_blueprint())
|
|
|
|
# TODO: Initialize features when modules are available
|
|
# # Initialize user experience features
|
|
# # keyboard_manager doesn't need init_app - it's a simple utility class
|
|
# bulk_operations_manager.init_app(app)
|
|
# preferences_manager.init_app(app)
|
|
# advanced_search_manager.init_app(app)
|
|
# undo_redo_manager.init_app(app)
|
|
#
|
|
# # Initialize Mobile & Accessibility features
|
|
# mobile_responsive_manager.init_app(app)
|
|
# touch_gesture_manager.init_app(app)
|
|
# accessibility_manager.init_app(app)
|
|
# screen_reader_manager.init_app(app)
|
|
# color_contrast_manager.init_app(app)
|
|
# multi_screen_manager.init_app(app)
|
|
|
|
# Global variables to store app state
|
|
series_app = None
|
|
is_scanning = False
|
|
is_downloading = False
|
|
is_paused = False
|
|
download_thread = None
|
|
download_progress = {}
|
|
download_queue = []
|
|
current_downloading = None
|
|
download_stats = {
|
|
'total_series': 0,
|
|
'completed_series': 0,
|
|
'current_episode': None,
|
|
'total_episodes': 0,
|
|
'completed_episodes': 0
|
|
}
|
|
|
|
def init_series_app():
|
|
"""Initialize the SeriesApp with configuration directory."""
|
|
global series_app
|
|
directory_to_search = config.anime_directory
|
|
series_app = SeriesApp(directory_to_search)
|
|
return series_app
|
|
|
|
# Initialize the app on startup
|
|
init_series_app()
|
|
|
|
# Initialize scheduler
|
|
# scheduler = init_scheduler(config, socketio)
|
|
|
|
def setup_scheduler_callbacks():
|
|
"""Setup callbacks for scheduler operations."""
|
|
|
|
def rescan_callback():
|
|
"""Callback for scheduled rescan operations."""
|
|
try:
|
|
# Reinit and scan
|
|
series_app.SerieScanner.Reinit()
|
|
series_app.SerieScanner.Scan()
|
|
|
|
# Refresh the series list
|
|
series_app.List = SerieList.SerieList(series_app.directory_to_search)
|
|
series_app.__InitList__()
|
|
|
|
return {"status": "success", "message": "Scheduled rescan completed"}
|
|
except Exception as e:
|
|
raise Exception(f"Scheduled rescan failed: {e}")
|
|
|
|
def download_callback():
|
|
"""Callback for auto-download after scheduled rescan."""
|
|
try:
|
|
if not series_app or not series_app.List:
|
|
return {"status": "skipped", "message": "No series data available"}
|
|
|
|
# Find series with missing episodes
|
|
series_with_missing = []
|
|
for serie in series_app.List.GetList():
|
|
if serie.episodeDict:
|
|
series_with_missing.append(serie)
|
|
|
|
if not series_with_missing:
|
|
return {"status": "skipped", "message": "No series with missing episodes found"}
|
|
|
|
# Note: Actual download implementation would go here
|
|
# For now, just return the count of series that would be downloaded
|
|
return {
|
|
"status": "started",
|
|
"message": f"Auto-download initiated for {len(series_with_missing)} series",
|
|
"series_count": len(series_with_missing)
|
|
}
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Auto-download failed: {e}")
|
|
|
|
# scheduler.set_rescan_callback(rescan_callback)
|
|
# scheduler.set_download_callback(download_callback)
|
|
|
|
# Setup scheduler callbacks
|
|
# setup_scheduler_callbacks()
|
|
|
|
# Initialize error handling and health monitoring
|
|
# try:
|
|
# init_health_monitoring()
|
|
# logging.info("Health monitoring initialized successfully")
|
|
# except Exception as e:
|
|
# logging.error(f"Failed to initialize health monitoring: {e}")
|
|
|
|
# Initialize performance monitoring
|
|
# try:
|
|
# init_performance_monitoring()
|
|
# logging.info("Performance monitoring initialized successfully")
|
|
# except Exception as e:
|
|
# logging.error(f"Failed to initialize performance monitoring: {e}")
|
|
|
|
# Initialize API integrations
|
|
# try:
|
|
# init_api_integrations()
|
|
# # Set export manager's series app reference
|
|
# export_manager.series_app = series_app
|
|
# logging.info("API integrations initialized successfully")
|
|
# except Exception as e:
|
|
# logging.error(f"Failed to initialize API integrations: {e}")
|
|
|
|
# Initialize database system
|
|
# try:
|
|
# init_database_system()
|
|
# logging.info("Database system initialized successfully")
|
|
# except Exception as e:
|
|
# logging.error(f"Failed to initialize database system: {e}")
|
|
|
|
# Register cleanup functions
|
|
@atexit.register
|
|
def cleanup_on_exit():
|
|
"""Clean up resources on application exit."""
|
|
try:
|
|
# cleanup_health_monitoring()
|
|
# cleanup_performance_monitoring()
|
|
# cleanup_api_integrations()
|
|
# cleanup_database_system()
|
|
logging.info("Application cleanup completed")
|
|
except Exception as e:
|
|
logging.error(f"Error during cleanup: {e}")
|
|
|
|
# UX JavaScript and CSS routes
|
|
@app.route('/static/js/keyboard-shortcuts.js')
|
|
def keyboard_shortcuts_js():
|
|
"""Serve keyboard shortcuts JavaScript."""
|
|
from flask import Response
|
|
js_content = keyboard_manager.get_shortcuts_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/drag-drop.js')
|
|
def drag_drop_js():
|
|
"""Serve drag and drop JavaScript."""
|
|
from flask import Response
|
|
js_content = drag_drop_manager.get_drag_drop_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/bulk-operations.js')
|
|
def bulk_operations_js():
|
|
"""Serve bulk operations JavaScript."""
|
|
from flask import Response
|
|
js_content = bulk_operations_manager.get_bulk_operations_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/user-preferences.js')
|
|
def user_preferences_js():
|
|
"""Serve user preferences JavaScript."""
|
|
from flask import Response
|
|
js_content = preferences_manager.get_preferences_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/advanced-search.js')
|
|
def advanced_search_js():
|
|
"""Serve advanced search JavaScript."""
|
|
from flask import Response
|
|
js_content = advanced_search_manager.get_search_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/undo-redo.js')
|
|
def undo_redo_js():
|
|
"""Serve undo/redo JavaScript."""
|
|
from flask import Response
|
|
js_content = undo_redo_manager.get_undo_redo_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
# Mobile & Accessibility JavaScript routes
|
|
@app.route('/static/js/mobile-responsive.js')
|
|
def mobile_responsive_js():
|
|
"""Serve mobile responsive JavaScript."""
|
|
from flask import Response
|
|
js_content = mobile_responsive_manager.get_mobile_responsive_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/touch-gestures.js')
|
|
def touch_gestures_js():
|
|
"""Serve touch gestures JavaScript."""
|
|
from flask import Response
|
|
js_content = touch_gesture_manager.get_touch_gesture_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/accessibility-features.js')
|
|
def accessibility_features_js():
|
|
"""Serve accessibility features JavaScript."""
|
|
from flask import Response
|
|
js_content = accessibility_manager.get_accessibility_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/screen-reader-support.js')
|
|
def screen_reader_support_js():
|
|
"""Serve screen reader support JavaScript."""
|
|
from flask import Response
|
|
js_content = screen_reader_manager.get_screen_reader_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/color-contrast-compliance.js')
|
|
def color_contrast_compliance_js():
|
|
"""Serve color contrast compliance JavaScript."""
|
|
from flask import Response
|
|
js_content = color_contrast_manager.get_contrast_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/js/multi-screen-support.js')
|
|
def multi_screen_support_js():
|
|
"""Serve multi-screen support JavaScript."""
|
|
from flask import Response
|
|
js_content = multi_screen_manager.get_multiscreen_js()
|
|
return Response(js_content, mimetype='application/javascript')
|
|
|
|
@app.route('/static/css/ux-features.css')
|
|
def ux_features_css():
|
|
"""Serve UX features CSS."""
|
|
from flask import Response
|
|
css_content = f"""
|
|
/* Keyboard shortcuts don't require additional CSS */
|
|
|
|
{drag_drop_manager.get_css()}
|
|
|
|
{bulk_operations_manager.get_css()}
|
|
|
|
{preferences_manager.get_css()}
|
|
|
|
{advanced_search_manager.get_css()}
|
|
|
|
{undo_redo_manager.get_css()}
|
|
|
|
/* Mobile & Accessibility CSS */
|
|
{mobile_responsive_manager.get_css()}
|
|
|
|
{touch_gesture_manager.get_css()}
|
|
|
|
{accessibility_manager.get_css()}
|
|
|
|
{screen_reader_manager.get_css()}
|
|
|
|
{color_contrast_manager.get_contrast_css()}
|
|
|
|
{multi_screen_manager.get_multiscreen_css()}
|
|
"""
|
|
return Response(css_content, mimetype='text/css')
|
|
|
|
@app.route('/')
|
|
@optional_auth
|
|
def index():
|
|
"""Main page route."""
|
|
# Check process status
|
|
process_status = {
|
|
'rescan_running': is_process_running(RESCAN_LOCK),
|
|
'download_running': is_process_running(DOWNLOAD_LOCK)
|
|
}
|
|
return render_template('index.html', process_status=process_status)
|
|
|
|
# Authentication routes
|
|
@app.route('/login')
|
|
def login():
|
|
"""Login page."""
|
|
if not config.has_master_password():
|
|
return redirect(url_for('setup'))
|
|
|
|
if session_manager.is_authenticated():
|
|
return redirect(url_for('index'))
|
|
|
|
return render_template('login.html',
|
|
session_timeout=config.session_timeout_hours,
|
|
max_attempts=config.max_failed_attempts,
|
|
lockout_duration=config.lockout_duration_minutes)
|
|
|
|
@app.route('/setup')
|
|
def setup():
|
|
"""Initial setup page."""
|
|
if config.has_master_password():
|
|
return redirect(url_for('login'))
|
|
|
|
return render_template('setup.html', current_directory=config.anime_directory)
|
|
|
|
@app.route('/api/auth/setup', methods=['POST'])
|
|
def auth_setup():
|
|
"""Complete initial setup."""
|
|
if config.has_master_password():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Setup already completed'
|
|
}), 400
|
|
|
|
try:
|
|
data = request.get_json()
|
|
password = data.get('password')
|
|
directory = data.get('directory')
|
|
|
|
if not password or len(password) < 8:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Password must be at least 8 characters long'
|
|
}), 400
|
|
|
|
if not directory:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Directory is required'
|
|
}), 400
|
|
|
|
# Set master password and directory
|
|
config.set_master_password(password)
|
|
config.anime_directory = directory
|
|
config.save_config()
|
|
|
|
# Reinitialize series app with new directory
|
|
init_series_app()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Setup completed successfully'
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': str(e)
|
|
}), 500
|
|
|
|
@app.route('/api/auth/login', methods=['POST'])
|
|
def auth_login():
|
|
"""Authenticate user."""
|
|
try:
|
|
data = request.get_json()
|
|
password = data.get('password')
|
|
|
|
if not password:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Password is required'
|
|
}), 400
|
|
|
|
# Verify password using session manager
|
|
result = session_manager.login(password, request.remote_addr)
|
|
|
|
return jsonify(result)
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': str(e)
|
|
}), 500
|
|
|
|
@app.route('/api/auth/logout', methods=['POST'])
|
|
@require_auth
|
|
def auth_logout():
|
|
"""Logout user."""
|
|
session_manager.logout()
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Logged out successfully'
|
|
})
|
|
|
|
@app.route('/api/auth/status', methods=['GET'])
|
|
def auth_status():
|
|
"""Get authentication status."""
|
|
return jsonify({
|
|
'authenticated': session_manager.is_authenticated(),
|
|
'has_master_password': config.has_master_password(),
|
|
'setup_required': not config.has_master_password(),
|
|
'session_info': session_manager.get_session_info()
|
|
})
|
|
|
|
@app.route('/api/config/directory', methods=['POST'])
|
|
@require_auth
|
|
def update_directory():
|
|
"""Update anime directory configuration."""
|
|
try:
|
|
data = request.get_json()
|
|
new_directory = data.get('directory')
|
|
|
|
if not new_directory:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Directory is required'
|
|
}), 400
|
|
|
|
# Update configuration
|
|
config.anime_directory = new_directory
|
|
config.save_config()
|
|
|
|
# Reinitialize series app
|
|
init_series_app()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Directory updated successfully',
|
|
'directory': new_directory
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
@app.route('/api/series', methods=['GET'])
|
|
@optional_auth
|
|
def get_series():
|
|
"""Get all series data."""
|
|
try:
|
|
if series_app is None or series_app.List is None:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'series': [],
|
|
'total_series': 0,
|
|
'message': 'No series data available. Please perform a scan to load series.'
|
|
})
|
|
|
|
# Get series data
|
|
series_data = []
|
|
for serie in series_app.List.GetList():
|
|
series_data.append({
|
|
'folder': serie.folder,
|
|
'name': serie.name or serie.folder,
|
|
'total_episodes': sum(len(episodes) for episodes in serie.episodeDict.values()),
|
|
'missing_episodes': sum(len(episodes) for episodes in serie.episodeDict.values()),
|
|
'status': 'ongoing',
|
|
'episodes': {
|
|
season: episodes
|
|
for season, episodes in serie.episodeDict.items()
|
|
}
|
|
})
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'series': series_data,
|
|
'total_series': len(series_data)
|
|
})
|
|
|
|
except Exception as e:
|
|
# Log the error but don't return 500 to prevent page reload loops
|
|
print(f"Error in get_series: {e}")
|
|
return jsonify({
|
|
'status': 'success',
|
|
'series': [],
|
|
'total_series': 0,
|
|
'message': 'Error loading series data. Please try rescanning.'
|
|
})
|
|
|
|
@app.route('/api/search', methods=['POST'])
|
|
@optional_auth
|
|
@handle_api_errors
|
|
def search_series():
|
|
"""Search for series online."""
|
|
try:
|
|
# Get the search query from the request
|
|
data = request.get_json()
|
|
if not data or 'query' not in data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Search query is required'
|
|
}), 400
|
|
|
|
query = data['query'].strip()
|
|
if not query:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Search query cannot be empty'
|
|
}), 400
|
|
|
|
# Check if series_app is available
|
|
if series_app is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Series application not initialized'
|
|
}), 500
|
|
|
|
# Perform the search
|
|
search_results = series_app.search(query)
|
|
|
|
# Format results for the frontend
|
|
results = []
|
|
if search_results:
|
|
for result in search_results:
|
|
if isinstance(result, dict) and 'name' in result and 'link' in result:
|
|
results.append({
|
|
'name': result['name'],
|
|
'link': result['link']
|
|
})
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'results': results,
|
|
'total': len(results)
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Search failed: {str(e)}'
|
|
}), 500
|
|
|
|
@app.route('/api/add_series', methods=['POST'])
|
|
@optional_auth
|
|
@handle_api_errors
|
|
def add_series():
|
|
"""Add a new series to the collection."""
|
|
try:
|
|
# Get the request data
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Request data is required'
|
|
}), 400
|
|
|
|
# Validate required fields
|
|
if 'link' not in data or 'name' not in data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Both link and name are required'
|
|
}), 400
|
|
|
|
link = data['link'].strip()
|
|
name = data['name'].strip()
|
|
|
|
if not link or not name:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Link and name cannot be empty'
|
|
}), 400
|
|
|
|
# Check if series_app is available
|
|
if series_app is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Series application not initialized'
|
|
}), 500
|
|
|
|
# Create and add the series
|
|
new_serie = Serie(link, name, "aniworld.to", link, {})
|
|
series_app.List.add(new_serie)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Series "{name}" added successfully'
|
|
})
|
|
|
|
except Exception as e:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Failed to add series: {str(e)}'
|
|
}), 500
|
|
|
|
@app.route('/api/rescan', methods=['POST'])
|
|
@optional_auth
|
|
def rescan_series():
|
|
"""Rescan/reinit the series directory."""
|
|
global is_scanning
|
|
|
|
# Check if rescan is already running using process lock
|
|
if is_process_running(RESCAN_LOCK) or is_scanning:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Rescan is already running. Please wait for it to complete.',
|
|
'is_running': True
|
|
}), 409
|
|
|
|
def scan_thread():
|
|
global is_scanning
|
|
|
|
try:
|
|
# Use process lock to prevent duplicate rescans
|
|
@with_process_lock(RESCAN_LOCK, timeout_minutes=120)
|
|
def perform_rescan():
|
|
global is_scanning
|
|
is_scanning = True
|
|
|
|
try:
|
|
# Emit scanning started
|
|
socketio.emit('scan_started')
|
|
|
|
# Reinit and scan
|
|
series_app.SerieScanner.Reinit()
|
|
series_app.SerieScanner.Scan(lambda folder, counter:
|
|
socketio.emit('scan_progress', {
|
|
'folder': folder,
|
|
'counter': counter
|
|
})
|
|
)
|
|
|
|
# Refresh the series list
|
|
series_app.List = SerieList.SerieList(series_app.directory_to_search)
|
|
series_app.__InitList__()
|
|
|
|
# Emit scan completed
|
|
socketio.emit('scan_completed')
|
|
|
|
except Exception as e:
|
|
socketio.emit('scan_error', {'message': str(e)})
|
|
raise
|
|
finally:
|
|
is_scanning = False
|
|
|
|
perform_rescan(_locked_by='web_interface')
|
|
|
|
except ProcessLockError:
|
|
socketio.emit('scan_error', {'message': 'Rescan is already running'})
|
|
except Exception as e:
|
|
socketio.emit('scan_error', {'message': str(e)})
|
|
|
|
# Start scan in background thread
|
|
threading.Thread(target=scan_thread, daemon=True).start()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Rescan started'
|
|
})
|
|
|
|
# Basic download endpoint - simplified for now
|
|
@app.route('/api/download', methods=['POST'])
|
|
@optional_auth
|
|
def download_series():
|
|
"""Download selected series."""
|
|
global is_downloading
|
|
|
|
# Check if download is already running using process lock
|
|
if is_process_running(DOWNLOAD_LOCK) or is_downloading:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Download is already running. Please wait for it to complete.',
|
|
'is_running': True
|
|
}), 409
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Download functionality will be implemented with queue system'
|
|
})
|
|
|
|
# WebSocket events for real-time updates
|
|
@socketio.on('connect')
|
|
def handle_connect():
|
|
"""Handle client connection."""
|
|
emit('status', {
|
|
'message': 'Connected to server',
|
|
'processes': {
|
|
'rescan_running': is_process_running(RESCAN_LOCK),
|
|
'download_running': is_process_running(DOWNLOAD_LOCK)
|
|
}
|
|
})
|
|
|
|
@socketio.on('disconnect')
|
|
def handle_disconnect():
|
|
"""Handle client disconnection."""
|
|
print('Client disconnected')
|
|
|
|
@socketio.on('get_status')
|
|
def handle_get_status():
|
|
"""Handle status request."""
|
|
emit('status_update', {
|
|
'processes': {
|
|
'rescan_running': is_process_running(RESCAN_LOCK),
|
|
'download_running': is_process_running(DOWNLOAD_LOCK)
|
|
},
|
|
'series_count': len(series_app.List.GetList()) if series_app and series_app.List else 0
|
|
})
|
|
|
|
# Error Recovery and Diagnostics Endpoints
|
|
@app.route('/api/process/locks/status', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def process_locks_status():
|
|
"""Get current process lock status."""
|
|
try:
|
|
# Use the constants and functions defined above in this file
|
|
|
|
locks = {
|
|
'rescan': {
|
|
'is_locked': is_process_running(RESCAN_LOCK),
|
|
'locked_by': 'system' if is_process_running(RESCAN_LOCK) else None,
|
|
'lock_time': None # Could be extended to track actual lock times
|
|
},
|
|
'download': {
|
|
'is_locked': is_process_running(DOWNLOAD_LOCK),
|
|
'locked_by': 'system' if is_process_running(DOWNLOAD_LOCK) else None,
|
|
'lock_time': None # Could be extended to track actual lock times
|
|
}
|
|
}
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'locks': locks,
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'locks': {
|
|
'rescan': {'is_locked': False, 'locked_by': None, 'lock_time': None},
|
|
'download': {'is_locked': False, 'locked_by': None, 'lock_time': None}
|
|
}
|
|
})
|
|
|
|
@app.route('/api/status', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_status():
|
|
"""Get current system status."""
|
|
try:
|
|
# Get anime directory from environment or config
|
|
anime_directory = os.environ.get('ANIME_DIRECTORY', 'Not configured')
|
|
|
|
# Get series count (placeholder implementation)
|
|
series_count = 0
|
|
try:
|
|
# This would normally get the actual series count from your series scanner
|
|
# For now, return a placeholder value
|
|
series_count = 0
|
|
except Exception:
|
|
series_count = 0
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'directory': anime_directory,
|
|
'series_count': series_count,
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
except Exception as e:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e),
|
|
'directory': 'Error',
|
|
'series_count': 0
|
|
})
|
|
|
|
# Configuration API endpoints
|
|
@app.route('/api/scheduler/config', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_scheduler_config():
|
|
"""Get scheduler configuration."""
|
|
return jsonify({
|
|
'success': True,
|
|
'config': {
|
|
'enabled': False,
|
|
'time': '03:00',
|
|
'auto_download_after_rescan': False,
|
|
'next_run': None,
|
|
'last_run': None,
|
|
'is_running': False
|
|
}
|
|
})
|
|
|
|
@app.route('/api/scheduler/config', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def set_scheduler_config():
|
|
"""Set scheduler configuration."""
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Scheduler configuration saved (placeholder)'
|
|
})
|
|
|
|
@app.route('/api/logging/config', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_logging_config():
|
|
"""Get logging configuration."""
|
|
return jsonify({
|
|
'success': True,
|
|
'config': {
|
|
'log_level': 'INFO',
|
|
'enable_console_logging': True,
|
|
'enable_console_progress': True,
|
|
'enable_fail2ban_logging': False
|
|
}
|
|
})
|
|
|
|
@app.route('/api/logging/config', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def set_logging_config():
|
|
"""Set logging configuration."""
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Logging configuration saved (placeholder)'
|
|
})
|
|
|
|
@app.route('/api/logging/files', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_log_files():
|
|
"""Get available log files."""
|
|
return jsonify({
|
|
'success': True,
|
|
'files': []
|
|
})
|
|
|
|
@app.route('/api/logging/test', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def test_logging():
|
|
"""Test logging functionality."""
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Test logging completed (placeholder)'
|
|
})
|
|
|
|
@app.route('/api/logging/cleanup', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def cleanup_logs():
|
|
"""Clean up old log files."""
|
|
data = request.get_json()
|
|
days = data.get('days', 30)
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Log files older than {days} days have been cleaned up (placeholder)'
|
|
})
|
|
|
|
@app.route('/api/logging/files/<filename>/tail')
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def tail_log_file(filename):
|
|
"""Get the tail of a log file."""
|
|
lines = request.args.get('lines', 100, type=int)
|
|
return jsonify({
|
|
'success': True,
|
|
'content': f'Last {lines} lines of {filename} (placeholder)',
|
|
'filename': filename
|
|
})
|
|
|
|
@app.route('/api/config/section/advanced', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_advanced_config():
|
|
"""Get advanced configuration."""
|
|
return jsonify({
|
|
'success': True,
|
|
'config': {
|
|
'max_concurrent_downloads': 3,
|
|
'provider_timeout': 30,
|
|
'enable_debug_mode': False
|
|
}
|
|
})
|
|
|
|
@app.route('/api/config/section/advanced', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def set_advanced_config():
|
|
"""Set advanced configuration."""
|
|
data = request.get_json()
|
|
# Here you would normally save the configuration
|
|
# For now, we'll just return success
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Advanced configuration saved successfully'
|
|
})
|
|
|
|
@app.route('/api/config/backup', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def create_config_backup():
|
|
"""Create a configuration backup."""
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Configuration backup created successfully',
|
|
'filename': f'config_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
|
})
|
|
|
|
@app.route('/api/config/backups', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_config_backups():
|
|
"""Get list of configuration backups."""
|
|
return jsonify({
|
|
'success': True,
|
|
'backups': [] # Empty list for now - would normally list actual backup files
|
|
})
|
|
|
|
@app.route('/api/config/backup/<filename>/restore', methods=['POST'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def restore_config_backup(filename):
|
|
"""Restore a configuration backup."""
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Configuration restored from {filename}'
|
|
})
|
|
|
|
@app.route('/api/config/backup/<filename>/download', methods=['GET'])
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def download_config_backup(filename):
|
|
"""Download a configuration backup file."""
|
|
# For now, return an empty response - would normally serve the actual file
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Backup download endpoint (placeholder)'
|
|
})
|
|
|
|
@app.route('/api/diagnostics/network')
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def network_diagnostics():
|
|
"""Get network diagnostics and connectivity status."""
|
|
try:
|
|
network_status = network_health_checker.get_network_status()
|
|
|
|
# Test AniWorld connectivity
|
|
aniworld_reachable = network_health_checker.check_url_reachability("https://aniworld.to")
|
|
network_status['aniworld_reachable'] = aniworld_reachable
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': network_status
|
|
})
|
|
except Exception as e:
|
|
raise RetryableError(f"Network diagnostics failed: {e}")
|
|
|
|
@app.route('/api/diagnostics/errors')
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_error_history():
|
|
"""Get recent error history."""
|
|
try:
|
|
recent_errors = error_recovery_manager.error_history[-50:] # Last 50 errors
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'recent_errors': recent_errors,
|
|
'total_errors': len(error_recovery_manager.error_history),
|
|
'blacklisted_urls': list(error_recovery_manager.blacklisted_urls.keys())
|
|
}
|
|
})
|
|
except Exception as e:
|
|
raise RetryableError(f"Error history retrieval failed: {e}")
|
|
|
|
@app.route('/api/recovery/clear-blacklist', methods=['POST'])
|
|
@handle_api_errors
|
|
@require_auth
|
|
def clear_blacklist():
|
|
"""Clear URL blacklist."""
|
|
try:
|
|
error_recovery_manager.blacklisted_urls.clear()
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'URL blacklist cleared successfully'
|
|
})
|
|
except Exception as e:
|
|
raise RetryableError(f"Blacklist clearing failed: {e}")
|
|
|
|
@app.route('/api/recovery/retry-counts')
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def get_retry_counts():
|
|
"""Get retry statistics."""
|
|
try:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'retry_counts': error_recovery_manager.retry_counts,
|
|
'total_retries': sum(error_recovery_manager.retry_counts.values())
|
|
}
|
|
})
|
|
except Exception as e:
|
|
raise RetryableError(f"Retry statistics retrieval failed: {e}")
|
|
|
|
@app.route('/api/diagnostics/system-status')
|
|
@handle_api_errors
|
|
@optional_auth
|
|
def system_status_summary():
|
|
"""Get comprehensive system status summary."""
|
|
try:
|
|
# Get health status
|
|
health_status = health_monitor.get_current_health_status()
|
|
|
|
# Get network status
|
|
network_status = network_health_checker.get_network_status()
|
|
|
|
# Get process status
|
|
process_status = {
|
|
'rescan_running': is_process_running(RESCAN_LOCK),
|
|
'download_running': is_process_running(DOWNLOAD_LOCK)
|
|
}
|
|
|
|
# Get error statistics
|
|
error_stats = {
|
|
'total_errors': len(error_recovery_manager.error_history),
|
|
'recent_errors': len([e for e in error_recovery_manager.error_history
|
|
if (datetime.now() - datetime.fromisoformat(e['timestamp'])).seconds < 3600]),
|
|
'blacklisted_urls': len(error_recovery_manager.blacklisted_urls)
|
|
}
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'health': health_status,
|
|
'network': network_status,
|
|
'processes': process_status,
|
|
'errors': error_stats,
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
})
|
|
except Exception as e:
|
|
raise RetryableError(f"System status retrieval failed: {e}")
|
|
|
|
if __name__ == '__main__':
|
|
# Clean up any expired locks on startup
|
|
check_process_locks()
|
|
|
|
# Configure enhanced logging system
|
|
try:
|
|
from logging_config import get_logger, logging_config
|
|
logger = get_logger(__name__, 'webapp')
|
|
logger.info("Enhanced logging system initialized")
|
|
except ImportError:
|
|
# Fallback to basic logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning("Using fallback logging - enhanced logging not available")
|
|
|
|
logger.info("Starting Aniworld Flask server...")
|
|
logger.info(f"Anime directory: {config.anime_directory}")
|
|
logger.info(f"Log level: {config.log_level}")
|
|
|
|
# Start scheduler if enabled
|
|
# if config.scheduled_rescan_enabled:
|
|
# logger.info(f"Starting scheduler - daily rescan at {config.scheduled_rescan_time}")
|
|
# scheduler.start_scheduler()
|
|
# else:
|
|
logger.info("Scheduled operations disabled")
|
|
|
|
logger.info("Server will be available at http://localhost:5000")
|
|
|
|
try:
|
|
# Run with SocketIO
|
|
socketio.run(app, debug=True, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True)
|
|
finally:
|
|
# Clean shutdown
|
|
# if scheduler:
|
|
# scheduler.stop_scheduler()
|
|
# logger.info("Scheduler stopped")
|
|
pass # Placeholder for cleanup code |