- Remove nfo_scan and media_scan from loading page steps (no longer shown in UI) - Remove perform_nfo_scan_if_needed calls from fastapi_app and auth.py - Always redirect to /setup/unresolved after initialization completes instead of conditionally checking for unresolved folders - Fix middleware to allow access to /loading page - let it handle its own redirect flow via WebSocket events This ensures users always reach the unresolved folders page after initial setup to manually configure any unmatched anime series.
676 lines
27 KiB
Python
676 lines
27 KiB
Python
"""
|
|
FastAPI application for Aniworld anime download manager.
|
|
|
|
This module provides the main FastAPI application with proper CORS
|
|
configuration, middleware setup, static file serving, and Jinja2 template
|
|
integration.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from src.config.settings import settings
|
|
|
|
# Import core functionality
|
|
from src.infrastructure.logging import setup_logging
|
|
from src.server.api.anime import router as anime_router
|
|
from src.server.api.auth import router as auth_router
|
|
from src.server.api.config import router as config_router
|
|
from src.server.api.download import router as download_router
|
|
from src.server.api.health import router as health_router
|
|
from src.server.api.logging import router as logging_router
|
|
from src.server.api.nfo import router as nfo_router
|
|
from src.server.api.scheduler import router as scheduler_router
|
|
from src.server.api.setup_endpoints import router as setup_router
|
|
from src.server.api.websocket import router as websocket_router
|
|
from src.server.controllers.error_controller import (
|
|
not_found_handler,
|
|
server_error_handler,
|
|
)
|
|
|
|
# Import controllers
|
|
from src.server.controllers.page_controller import router as page_router
|
|
from src.server.middleware.auth import AuthMiddleware
|
|
from src.server.middleware.error_handler import register_exception_handlers
|
|
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
|
from src.server.services.progress_service import get_progress_service
|
|
from src.server.services.websocket_service import get_websocket_service
|
|
|
|
# Prefer storing application-wide singletons on FastAPI.state instead of
|
|
# module-level globals. This makes testing and multi-instance hosting safer.
|
|
|
|
|
|
async def _check_incomplete_series_on_startup(background_loader) -> None:
|
|
"""Check for incomplete series on startup and queue background loading.
|
|
|
|
Args:
|
|
background_loader: BackgroundLoaderService instance
|
|
"""
|
|
logger = logging.getLogger("aniworld")
|
|
|
|
try:
|
|
from src.server.database.connection import get_db_session
|
|
from src.server.database.service import AnimeSeriesService
|
|
|
|
async with get_db_session() as db:
|
|
try:
|
|
# Get all series from database
|
|
series_list = await AnimeSeriesService.get_all(db)
|
|
|
|
incomplete_series = []
|
|
|
|
for series in series_list:
|
|
# Check if series has incomplete loading
|
|
if series.loading_status != "completed":
|
|
incomplete_series.append(series)
|
|
# Or check if specific data is missing
|
|
elif (not series.episodes_loaded or
|
|
not series.has_nfo or
|
|
not series.logo_loaded or
|
|
not series.images_loaded):
|
|
incomplete_series.append(series)
|
|
|
|
if incomplete_series:
|
|
logger.info(
|
|
f"Found {len(incomplete_series)} series with missing data. "
|
|
f"Queuing for background loading..."
|
|
)
|
|
|
|
for series in incomplete_series:
|
|
await background_loader.add_series_loading_task(
|
|
key=series.key,
|
|
folder=series.folder,
|
|
name=series.name,
|
|
year=series.year
|
|
)
|
|
logger.debug(
|
|
f"Queued background loading for series: {series.key}"
|
|
)
|
|
|
|
logger.info("All incomplete series queued for background loading")
|
|
else:
|
|
logger.info("All series data is complete. No background loading needed.")
|
|
|
|
except Exception:
|
|
logger.exception("Error checking incomplete series")
|
|
|
|
except Exception:
|
|
logger.exception("Failed to check incomplete series on startup")
|
|
|
|
|
|
async def _run_startup_health_checks(logger) -> dict:
|
|
"""Run startup health checks for critical dependencies.
|
|
|
|
Checks:
|
|
- ffmpeg availability
|
|
- DNS resolution for aniworld.to and api.themoviedb.org
|
|
- anime_directory configuration and writability
|
|
|
|
Args:
|
|
logger: Logger instance for recording check results.
|
|
|
|
Returns:
|
|
dict: Health check results with status and details for each check.
|
|
"""
|
|
import asyncio
|
|
import shutil
|
|
import socket
|
|
from typing import Any, Dict
|
|
|
|
checks: Dict[str, Any] = {
|
|
"ffmpeg": {"status": "unknown", "message": None},
|
|
"dns_aniworld": {"status": "unknown", "message": None},
|
|
"dns_tmdb": {"status": "unknown", "message": None},
|
|
"anime_directory": {"status": "unknown", "message": None, "path": None},
|
|
}
|
|
|
|
# Check ffmpeg availability
|
|
try:
|
|
ffmpeg_path = shutil.which("ffmpeg")
|
|
if ffmpeg_path:
|
|
checks["ffmpeg"]["status"] = "ok"
|
|
checks["ffmpeg"]["message"] = f"Found at {ffmpeg_path}"
|
|
logger.debug("ffmpeg health check passed: %s", ffmpeg_path)
|
|
else:
|
|
checks["ffmpeg"]["status"] = "warning"
|
|
checks["ffmpeg"]["message"] = "ffmpeg not found in PATH"
|
|
logger.warning("ffmpeg health check failed: not in PATH")
|
|
except Exception as e:
|
|
checks["ffmpeg"]["status"] = "error"
|
|
checks["ffmpeg"]["message"] = str(e)
|
|
logger.warning("Could not check ffmpeg: %s", e)
|
|
|
|
# Check DNS resolution for aniworld.to
|
|
try:
|
|
socket.gethostbyname("aniworld.to")
|
|
checks["dns_aniworld"]["status"] = "ok"
|
|
checks["dns_aniworld"]["message"] = "Resolved successfully"
|
|
logger.debug("DNS health check passed for aniworld.to")
|
|
except socket.gaierror as e:
|
|
checks["dns_aniworld"]["status"] = "warning"
|
|
checks["dns_aniworld"]["message"] = f"DNS resolution failed: {e}"
|
|
logger.warning("DNS health check failed for aniworld.to: %s", e)
|
|
except Exception as e:
|
|
checks["dns_aniworld"]["status"] = "warning"
|
|
checks["dns_aniworld"]["message"] = f"Unexpected error: {e}"
|
|
logger.warning("Unexpected DNS error for aniworld.to: %s", e)
|
|
|
|
# Check DNS resolution for api.themoviedb.org
|
|
try:
|
|
socket.gethostbyname("api.themoviedb.org")
|
|
checks["dns_tmdb"]["status"] = "ok"
|
|
checks["dns_tmdb"]["message"] = "Resolved successfully"
|
|
logger.debug("DNS health check passed for api.themoviedb.org")
|
|
except socket.gaierror as e:
|
|
checks["dns_tmdb"]["status"] = "warning"
|
|
checks["dns_tmdb"]["message"] = f"DNS resolution failed: {e}"
|
|
logger.warning("DNS health check failed for api.themoviedb.org: %s", e)
|
|
except Exception as e:
|
|
checks["dns_tmdb"]["status"] = "warning"
|
|
checks["dns_tmdb"]["message"] = f"Unexpected error: {e}"
|
|
logger.warning("Unexpected DNS error for api.themoviedb.org: %s", e)
|
|
|
|
# Check anime_directory configuration and writability
|
|
from src.config.settings import settings
|
|
anime_dir = settings.anime_directory
|
|
|
|
if not anime_dir:
|
|
checks["anime_directory"]["status"] = "error"
|
|
checks["anime_directory"]["message"] = "anime_directory not configured"
|
|
checks["anime_directory"]["path"] = None
|
|
logger.error("anime_directory health check failed: not configured")
|
|
else:
|
|
import os
|
|
checks["anime_directory"]["path"] = anime_dir
|
|
|
|
if not os.path.isdir(anime_dir):
|
|
checks["anime_directory"]["status"] = "error"
|
|
checks["anime_directory"]["message"] = f"Directory does not exist: {anime_dir}"
|
|
logger.error("anime_directory health check failed: %s does not exist", anime_dir)
|
|
elif not os.access(anime_dir, os.W_OK):
|
|
checks["anime_directory"]["status"] = "error"
|
|
checks["anime_directory"]["message"] = f"Directory not writable: {anime_dir}"
|
|
logger.error("anime_directory health check failed: %s not writable", anime_dir)
|
|
else:
|
|
checks["anime_directory"]["status"] = "ok"
|
|
checks["anime_directory"]["message"] = f"Directory exists and is writable: {anime_dir}"
|
|
logger.debug("anime_directory health check passed: %s", anime_dir)
|
|
|
|
return checks
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_application: FastAPI):
|
|
"""Manage application lifespan (startup and shutdown).
|
|
|
|
Args:
|
|
_application: The FastAPI application instance (unused but required
|
|
by the lifespan protocol).
|
|
"""
|
|
# Setup logging first with INFO level
|
|
logger = setup_logging(log_level="INFO")
|
|
logger.info("Starting FastAPI application v%s", APP_VERSION)
|
|
|
|
# Track successful initialization steps
|
|
initialized = {
|
|
'database': False,
|
|
'services': False,
|
|
'background_loader': False,
|
|
'scheduler': False
|
|
}
|
|
|
|
# Startup
|
|
startup_error = None
|
|
try:
|
|
logger.info("Starting FastAPI application...")
|
|
|
|
# Clean up any leftover temp download files from a previous run
|
|
try:
|
|
import shutil as _shutil
|
|
_temp_dir = Path(__file__).resolve().parents[2] / "Temp"
|
|
if _temp_dir.exists():
|
|
_removed = 0
|
|
for _item in _temp_dir.iterdir():
|
|
try:
|
|
if _item.is_file():
|
|
_item.unlink()
|
|
elif _item.is_dir():
|
|
_shutil.rmtree(_item)
|
|
_removed += 1
|
|
except OSError as _exc:
|
|
logger.warning("Could not remove temp item %s: %s", _item, _exc)
|
|
logger.info("Cleaned %d item(s) from Temp folder on startup", _removed)
|
|
else:
|
|
_temp_dir.mkdir(parents=True, exist_ok=True)
|
|
logger.debug("Created Temp folder: %s", _temp_dir)
|
|
except Exception as _exc:
|
|
logger.warning("Failed to clean Temp folder on startup: %s", _exc)
|
|
|
|
# Initialize database first (required for other services)
|
|
try:
|
|
from src.server.database.connection import init_db
|
|
await init_db()
|
|
initialized['database'] = True
|
|
logger.info("Database initialized successfully")
|
|
except Exception as e:
|
|
logger.error("Failed to initialize database: %s", e, exc_info=True)
|
|
startup_error = e
|
|
raise # Database is required, fail startup if it fails
|
|
|
|
# Load configuration from config.json and sync with settings
|
|
# Precedence: ENV vars > config.json > defaults
|
|
# Only sync from config.json if setting is at default value
|
|
try:
|
|
from src.server.services.config_service import get_config_service
|
|
config_service = get_config_service()
|
|
config = config_service.load_config()
|
|
|
|
logger.debug(
|
|
"Config loaded: other=%s", config.other
|
|
)
|
|
|
|
# Sync anime_directory from config.json to settings
|
|
# Only if not already set via ENV var (i.e., still empty)
|
|
other_settings = dict(config.other) if config.other else {}
|
|
if other_settings.get("anime_directory"):
|
|
anime_dir = other_settings["anime_directory"]
|
|
# Only override if settings.anime_directory is empty (default)
|
|
if not settings.anime_directory:
|
|
settings.anime_directory = str(anime_dir)
|
|
logger.info(
|
|
"Loaded anime_directory from config.json: %s",
|
|
settings.anime_directory
|
|
)
|
|
else:
|
|
logger.info(
|
|
"anime_directory from ENV var takes precedence: %s",
|
|
settings.anime_directory
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"anime_directory not found in config.other"
|
|
)
|
|
|
|
# Sync NFO settings from config.json to settings
|
|
# Only if not already set via ENV var
|
|
if config.nfo:
|
|
# TMDB API key: ENV takes precedence
|
|
if config.nfo.tmdb_api_key and not settings.tmdb_api_key:
|
|
settings.tmdb_api_key = config.nfo.tmdb_api_key
|
|
logger.info("Loaded TMDB API key from config.json")
|
|
elif settings.tmdb_api_key:
|
|
logger.info("Using TMDB API key from ENV var")
|
|
|
|
# NFO boolean flags: Sync from config.json
|
|
# (These have proper defaults, so we can sync them)
|
|
settings.nfo_auto_create = config.nfo.auto_create
|
|
settings.nfo_update_on_scan = config.nfo.update_on_scan
|
|
settings.nfo_download_poster = config.nfo.download_poster
|
|
settings.nfo_download_logo = config.nfo.download_logo
|
|
settings.nfo_download_fanart = config.nfo.download_fanart
|
|
settings.nfo_image_size = config.nfo.image_size
|
|
logger.debug("Synced NFO settings from config.json")
|
|
except (OSError, ValueError, KeyError) as e:
|
|
logger.warning("Failed to load config from config.json: %s", e)
|
|
|
|
# Initialize progress service with event subscription
|
|
progress_service = get_progress_service()
|
|
ws_service = get_websocket_service()
|
|
|
|
async def progress_event_handler(event) -> None:
|
|
"""Handle progress events and broadcast via WebSocket.
|
|
|
|
Args:
|
|
event: ProgressEvent containing progress update data
|
|
"""
|
|
message = {
|
|
"type": event.event_type,
|
|
"data": event.progress.to_dict(),
|
|
}
|
|
await ws_service.manager.broadcast_to_room(message, event.room)
|
|
|
|
# Subscribe to progress events
|
|
progress_service.subscribe("progress_updated", progress_event_handler)
|
|
|
|
# Perform initial setup (series sync and marking as completed)
|
|
# This is centralized in initialization_service and also called
|
|
# from the setup endpoint
|
|
from src.server.services.initialization_service import (
|
|
perform_initial_setup,
|
|
perform_media_scan_if_needed,
|
|
)
|
|
|
|
try:
|
|
logger.info(
|
|
"Checking anime_directory setting: '%s'",
|
|
settings.anime_directory
|
|
)
|
|
|
|
if settings.anime_directory:
|
|
# Perform initial setup if needed
|
|
await perform_initial_setup()
|
|
|
|
# Get anime service and load series — isolated so a missing
|
|
# directory doesn't abort the rest of the startup sequence
|
|
try:
|
|
from src.server.utils.dependencies import get_anime_service
|
|
anime_service = get_anime_service()
|
|
|
|
# Always load series from database into memory on startup
|
|
logger.info("Loading series from database into memory...")
|
|
await anime_service._load_series_from_db()
|
|
logger.info("Series loaded from database into memory")
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Could not load series into memory (directory may not "
|
|
"exist yet): %s", e
|
|
)
|
|
|
|
# Initialize download service
|
|
try:
|
|
from src.server.utils.dependencies import get_download_service
|
|
download_service = get_download_service()
|
|
await download_service.initialize()
|
|
initialized['services'] = True
|
|
logger.info("Download service initialized and queue restored")
|
|
except Exception as e:
|
|
logger.warning("Failed to initialize download service: %s", e)
|
|
|
|
# Initialize background loader service
|
|
background_loader = None
|
|
try:
|
|
from src.server.utils.dependencies import (
|
|
get_background_loader_service,
|
|
)
|
|
background_loader = get_background_loader_service()
|
|
await background_loader.start()
|
|
initialized['background_loader'] = True
|
|
logger.info("Background loader service started")
|
|
except Exception as e:
|
|
logger.warning("Failed to start background loader service: %s", e)
|
|
|
|
# Run media scan only on first run
|
|
await perform_media_scan_if_needed(background_loader)
|
|
else:
|
|
logger.info(
|
|
"Download service initialization skipped - "
|
|
"anime directory not configured"
|
|
)
|
|
|
|
# Initialize and start scheduler service (independent of anime_directory)
|
|
# The scheduler loads its own config from config.json and the
|
|
# anime_directory may be configured there even if the env var is empty.
|
|
try:
|
|
logger.info("Initializing scheduler service...")
|
|
from src.server.services.scheduler.scheduler_service import (
|
|
get_scheduler_service,
|
|
)
|
|
scheduler_service = get_scheduler_service()
|
|
logger.info("Scheduler service instance obtained, starting...")
|
|
await scheduler_service.start()
|
|
initialized['scheduler'] = True
|
|
logger.info("Scheduler service started successfully")
|
|
except Exception as e:
|
|
logger.warning("Failed to start scheduler service: %s", e)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
logger.warning("Failed to initialize services: %s", e)
|
|
# Continue startup - services can be initialized later
|
|
except Exception as e:
|
|
logger.warning("Unexpected error during startup initialization: %s", e)
|
|
# Continue startup - services can be configured/initialized later
|
|
|
|
logger.info("FastAPI application started successfully")
|
|
logger.info("Server running on http://127.0.0.1:8000")
|
|
logger.info(
|
|
"API documentation available at http://127.0.0.1:8000/api/docs"
|
|
)
|
|
|
|
# Check for ffmpeg availability and warn if missing
|
|
try:
|
|
import shutil as _shutil
|
|
if _shutil.which("ffmpeg") is None:
|
|
logger.warning(
|
|
"ffmpeg not found in PATH. HLS streams may fail to download. "
|
|
"Install ffmpeg to enable HLS support."
|
|
)
|
|
else:
|
|
logger.debug("ffmpeg found at: %s", _shutil.which("ffmpeg"))
|
|
except Exception as _exc:
|
|
logger.warning("Could not check for ffmpeg: %s", _exc)
|
|
|
|
# Run startup health checks and store results for /health endpoint
|
|
try:
|
|
startup_checks = await _run_startup_health_checks(logger)
|
|
app.state.startup_checks = startup_checks
|
|
except Exception as _exc:
|
|
logger.warning("Could not run startup health checks: %s", _exc)
|
|
app.state.startup_checks = {}
|
|
except Exception as e:
|
|
logger.error("Error during startup: %s", e, exc_info=True)
|
|
startup_error = e
|
|
# Don't re-raise here, let the finally/cleanup handle shutdown
|
|
|
|
# Yield control to the application (or immediately go to cleanup on error)
|
|
if startup_error is None:
|
|
try:
|
|
yield
|
|
except Exception as e:
|
|
logger.error("Error during application runtime: %s", e, exc_info=True)
|
|
else:
|
|
# Startup failed, but we still need to yield to satisfy the protocol
|
|
# The app won't actually run since we'll raise after cleanup
|
|
try:
|
|
yield
|
|
finally:
|
|
# After cleanup, re-raise the startup error
|
|
pass
|
|
|
|
# Shutdown - execute in proper order with timeout protection
|
|
logger.info("FastAPI application shutting down (graceful shutdown initiated)")
|
|
|
|
# Only cleanup what was successfully initialized
|
|
if not initialized['database']:
|
|
logger.info("Database was not initialized, skipping all cleanup")
|
|
if startup_error:
|
|
raise startup_error
|
|
return
|
|
|
|
# Define shutdown timeout (total time allowed for all shutdown operations)
|
|
SHUTDOWN_TIMEOUT = 30.0
|
|
|
|
import time
|
|
shutdown_start = time.monotonic()
|
|
|
|
def remaining_time() -> float:
|
|
"""Calculate remaining shutdown time."""
|
|
elapsed = time.monotonic() - shutdown_start
|
|
return max(0.0, SHUTDOWN_TIMEOUT - elapsed)
|
|
|
|
# 1. Stop scheduler service (only if initialized)
|
|
if initialized['scheduler']:
|
|
try:
|
|
from src.server.services.scheduler.scheduler_service import (
|
|
get_scheduler_service,
|
|
)
|
|
scheduler_service = get_scheduler_service()
|
|
logger.info("Stopping scheduler service...")
|
|
await asyncio.wait_for(
|
|
scheduler_service.stop(),
|
|
timeout=min(5.0, remaining_time())
|
|
)
|
|
logger.info("Scheduler service stopped")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("Scheduler service shutdown timed out")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error stopping scheduler service: %s", e, exc_info=True)
|
|
|
|
# 2. Stop background loader service (only if initialized)
|
|
if initialized['background_loader']:
|
|
try:
|
|
from src.server.utils.dependencies import _background_loader_service
|
|
if _background_loader_service is not None:
|
|
logger.info("Stopping background loader service...")
|
|
await asyncio.wait_for(
|
|
_background_loader_service.stop(),
|
|
timeout=min(10.0, remaining_time())
|
|
)
|
|
logger.info("Background loader service stopped")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("Background loader service shutdown timed out")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error stopping background loader service: %s", e, exc_info=True)
|
|
|
|
# 3. Broadcast shutdown notification via WebSocket
|
|
try:
|
|
ws_service = get_websocket_service()
|
|
logger.info("Broadcasting shutdown notification to WebSocket clients...")
|
|
await asyncio.wait_for(
|
|
ws_service.shutdown(timeout=min(5.0, remaining_time())),
|
|
timeout=min(5.0, remaining_time())
|
|
)
|
|
logger.info("WebSocket shutdown complete")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("WebSocket shutdown timed out")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error during WebSocket shutdown: %s", e, exc_info=True)
|
|
|
|
# 4. Shutdown download service and persist active downloads
|
|
try:
|
|
from src.server.services.download_service import (
|
|
_download_service_instance, # noqa: E501
|
|
)
|
|
if _download_service_instance is not None:
|
|
logger.info("Stopping download service...")
|
|
logger.info("Download service stopped successfully")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("Download service shutdown timed out")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error stopping download service: %s", e, exc_info=True)
|
|
|
|
# 4. Shutdown SeriesApp and cleanup thread pool
|
|
try:
|
|
from src.server.utils.dependencies import _series_app
|
|
if _series_app is not None:
|
|
logger.info("Shutting down SeriesApp thread pool...")
|
|
_series_app.shutdown()
|
|
logger.info("SeriesApp shutdown complete")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error during SeriesApp shutdown: %s", e, exc_info=True)
|
|
|
|
# 5. Cleanup progress service
|
|
try:
|
|
progress_service = get_progress_service()
|
|
logger.info("Cleaning up progress service...")
|
|
# Clear any active progress tracking and subscribers
|
|
progress_service._active_progress.clear()
|
|
logger.info("Progress service cleanup complete")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error(
|
|
"Error cleaning up progress service: %s", e, exc_info=True
|
|
)
|
|
|
|
# 5. Close database connections with WAL checkpoint
|
|
try:
|
|
from src.server.database.connection import close_db
|
|
logger.info("Closing database connections...")
|
|
await asyncio.wait_for(
|
|
close_db(),
|
|
timeout=min(10.0, remaining_time())
|
|
)
|
|
logger.info("Database connections closed")
|
|
except asyncio.TimeoutError:
|
|
logger.warning("Database shutdown timed out")
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
logger.error("Error closing database: %s", e, exc_info=True)
|
|
|
|
elapsed_total = time.monotonic() - shutdown_start
|
|
logger.info(
|
|
"FastAPI application shutdown complete (took %.2fs)",
|
|
elapsed_total
|
|
)
|
|
|
|
# Re-raise startup error if it occurred
|
|
if startup_error:
|
|
raise startup_error
|
|
|
|
|
|
from src.server.utils.version import APP_VERSION
|
|
|
|
# Initialize FastAPI app with lifespan
|
|
app = FastAPI(
|
|
title="Aniworld Download Manager",
|
|
description="Modern web interface for Aniworld anime download management",
|
|
version=APP_VERSION,
|
|
docs_url="/api/docs",
|
|
redoc_url="/api/redoc",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# Configure CORS using environment-driven configuration.
|
|
allowed_origins = settings.allowed_origins or [
|
|
"http://localhost:3000",
|
|
"http://localhost:8000",
|
|
]
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=allowed_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Configure static files
|
|
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
# Attach setup redirect middleware (runs before auth checks)
|
|
app.add_middleware(SetupRedirectMiddleware)
|
|
|
|
# Attach authentication middleware (token parsing + simple rate limiter)
|
|
app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
|
|
|
# Include routers
|
|
app.include_router(health_router)
|
|
app.include_router(page_router)
|
|
app.include_router(auth_router)
|
|
app.include_router(config_router)
|
|
app.include_router(scheduler_router)
|
|
app.include_router(anime_router)
|
|
app.include_router(download_router)
|
|
app.include_router(nfo_router)
|
|
app.include_router(setup_router)
|
|
app.include_router(logging_router)
|
|
app.include_router(websocket_router)
|
|
|
|
# Register exception handlers
|
|
register_exception_handlers(app)
|
|
|
|
|
|
@app.exception_handler(404)
|
|
async def handle_not_found(request: Request, exc: HTTPException):
|
|
"""Custom 404 handler."""
|
|
return await not_found_handler(request, exc)
|
|
|
|
|
|
@app.exception_handler(500)
|
|
async def handle_server_error(request: Request, exc: Exception):
|
|
"""Custom 500 handler."""
|
|
return await server_error_handler(request, exc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
"fastapi_app:app",
|
|
host="127.0.0.1",
|
|
port=8000,
|
|
reload=True,
|
|
log_level="info"
|
|
)
|