instruction2
This commit is contained in:
@@ -1,612 +0,0 @@
|
||||
"""
|
||||
FastAPI-based AniWorld Server Application.
|
||||
|
||||
This module implements a comprehensive FastAPI application following the instructions:
|
||||
- Simple master password authentication using JWT
|
||||
- Repository pattern with dependency injection
|
||||
- Proper error handling and validation
|
||||
- OpenAPI documentation
|
||||
- Security best practices
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import jwt
|
||||
|
||||
# Add parent directory to path for imports
|
||||
current_dir = os.path.dirname(__file__)
|
||||
parent_dir = os.path.join(current_dir, '..')
|
||||
project_root = os.path.join(parent_dir, '..') # Go up two levels to reach project root
|
||||
sys.path.insert(0, os.path.abspath(project_root))
|
||||
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Security, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config.settings import settings
|
||||
|
||||
# Application flow services will be imported lazily where needed to avoid
|
||||
# import-time circular dependencies during tests.
|
||||
SetupService = None
|
||||
ApplicationFlowMiddleware = None
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('./logs/aniworld.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Security
|
||||
security = HTTPBearer()
|
||||
|
||||
# Settings are loaded from `src.config.settings.settings`
|
||||
|
||||
# Pydantic Models
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request model."""
|
||||
password: str = Field(..., min_length=1, description="Master password")
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response model."""
|
||||
success: bool
|
||||
message: str
|
||||
token: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token verification response model."""
|
||||
valid: bool
|
||||
message: str
|
||||
user: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
status: str
|
||||
timestamp: datetime
|
||||
version: str = "1.0.0"
|
||||
services: Dict[str, str]
|
||||
|
||||
class AnimeSearchRequest(BaseModel):
|
||||
"""Anime search request model."""
|
||||
query: str = Field(..., min_length=1, max_length=100)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
class AnimeResponse(BaseModel):
|
||||
"""Anime response model."""
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
episodes: int = 0
|
||||
status: str = "Unknown"
|
||||
poster_url: Optional[str] = None
|
||||
|
||||
class EpisodeResponse(BaseModel):
|
||||
"""Episode response model."""
|
||||
id: str
|
||||
anime_id: str
|
||||
episode_number: int
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
stream_url: Optional[str] = None
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model."""
|
||||
success: bool = False
|
||||
error: str
|
||||
code: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
"""Setup request model."""
|
||||
password: str = Field(..., min_length=8, description="Master password (min 8 characters)")
|
||||
directory: str = Field(..., min_length=1, description="Anime directory path")
|
||||
|
||||
class SetupResponse(BaseModel):
|
||||
"""Setup response model."""
|
||||
status: str
|
||||
message: str
|
||||
redirect_url: Optional[str] = None
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""Setup status response model."""
|
||||
setup_complete: bool
|
||||
requirements: Dict[str, bool]
|
||||
missing_requirements: List[str]
|
||||
|
||||
# Authentication utilities
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password with salt using SHA-256."""
|
||||
salted_password = password + settings.password_salt
|
||||
return hashlib.sha256(salted_password.encode()).hexdigest()
|
||||
|
||||
def verify_master_password(password: str) -> bool:
|
||||
"""Verify password against master password hash."""
|
||||
if not settings.master_password_hash:
|
||||
# If no hash is set, check against plain password (development only)
|
||||
if settings.master_password:
|
||||
return password == settings.master_password
|
||||
return False
|
||||
|
||||
password_hash = hash_password(password)
|
||||
return password_hash == settings.master_password_hash
|
||||
|
||||
def generate_jwt_token() -> Dict[str, Any]:
|
||||
"""Generate JWT token for authentication."""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=settings.token_expiry_hours)
|
||||
payload = {
|
||||
'user': 'master',
|
||||
'exp': expires_at,
|
||||
'iat': datetime.utcnow(),
|
||||
'iss': 'aniworld-fastapi-server'
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, settings.jwt_secret_key, algorithm='HS256')
|
||||
return {
|
||||
'token': token,
|
||||
'expires_at': expires_at
|
||||
}
|
||||
|
||||
def verify_jwt_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify and decode JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=['HS256'])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid token: {str(e)}")
|
||||
return None
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)):
|
||||
"""Dependency to get current authenticated user."""
|
||||
token = credentials.credentials
|
||||
payload = verify_jwt_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
# Global exception handler
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Global exception handler for unhandled errors."""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"error": "Internal Server Error",
|
||||
"code": "INTERNAL_ERROR"
|
||||
}
|
||||
)
|
||||
|
||||
# Application lifespan
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan events."""
|
||||
# Startup
|
||||
logger.info("Starting AniWorld FastAPI server...")
|
||||
logger.info(f"Anime directory: {settings.anime_directory}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
|
||||
# Verify configuration
|
||||
if not settings.master_password_hash and not settings.master_password:
|
||||
logger.warning("No master password configured! Set MASTER_PASSWORD_HASH or MASTER_PASSWORD environment variable.")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down AniWorld FastAPI server...")
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="AniWorld API",
|
||||
description="""
|
||||
## AniWorld Management System
|
||||
|
||||
A comprehensive FastAPI-based application for managing anime series and episodes.
|
||||
|
||||
### Features
|
||||
|
||||
* **Series Management**: Search, track, and manage anime series
|
||||
* **Episode Tracking**: Monitor missing episodes and download progress
|
||||
* **Authentication**: Secure master password authentication with JWT tokens
|
||||
* **Real-time Updates**: WebSocket support for live progress tracking
|
||||
* **File Management**: Automatic file scanning and organization
|
||||
* **Download Queue**: Queue-based download management system
|
||||
|
||||
### Authentication
|
||||
|
||||
Most endpoints require authentication using a master password.
|
||||
Use the `/auth/login` endpoint to obtain a JWT token, then include it
|
||||
in the `Authorization` header as `Bearer <token>`.
|
||||
|
||||
### API Versioning
|
||||
|
||||
This API follows semantic versioning. Current version: **1.0.0**
|
||||
""",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
contact={
|
||||
"name": "AniWorld API Support",
|
||||
"url": "https://github.com/your-repo/aniworld",
|
||||
"email": "support@aniworld.com",
|
||||
},
|
||||
license_info={
|
||||
"name": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT",
|
||||
},
|
||||
tags_metadata=[
|
||||
{
|
||||
"name": "Authentication",
|
||||
"description": "Operations related to user authentication and session management",
|
||||
},
|
||||
{
|
||||
"name": "Anime",
|
||||
"description": "Operations for searching and managing anime series",
|
||||
},
|
||||
{
|
||||
"name": "Episodes",
|
||||
"description": "Operations for managing individual episodes",
|
||||
},
|
||||
{
|
||||
"name": "Downloads",
|
||||
"description": "Operations for managing the download queue and progress",
|
||||
},
|
||||
{
|
||||
"name": "System",
|
||||
"description": "System health, configuration, and maintenance operations",
|
||||
},
|
||||
{
|
||||
"name": "Files",
|
||||
"description": "File system operations and scanning functionality",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Configure templates
|
||||
templates = Jinja2Templates(directory="src/server/web/templates")
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="src/server/web/static"), name="static")
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add application flow middleware (import lazily to avoid circular imports during tests)
|
||||
try:
|
||||
if SetupService is None:
|
||||
from src.server.middleware.application_flow_middleware import (
|
||||
ApplicationFlowMiddleware as _AppFlow,
|
||||
)
|
||||
from src.server.services.setup_service import SetupService as _SetupService
|
||||
setup_service = _SetupService()
|
||||
app.add_middleware(_AppFlow, setup_service=setup_service)
|
||||
except Exception:
|
||||
# In test environments or minimal setups, middleware may be skipped
|
||||
pass
|
||||
|
||||
# Add global exception handler
|
||||
app.add_exception_handler(Exception, global_exception_handler)
|
||||
|
||||
from src.server.controllers.v2.anime_controller import router as anime_router
|
||||
|
||||
# Include controller routers (use v2 controllers to avoid circular imports)
|
||||
from src.server.controllers.v2.auth_controller import router as auth_router
|
||||
from src.server.controllers.v2.episode_controller import router as episode_router
|
||||
from src.server.controllers.v2.setup_controller import router as setup_router
|
||||
from src.server.controllers.v2.system_controller import router as system_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(anime_router)
|
||||
app.include_router(episode_router)
|
||||
app.include_router(system_router)
|
||||
|
||||
# Legacy API compatibility endpoints (TODO: migrate JavaScript to use v1 endpoints)
|
||||
@app.post("/api/add_series")
|
||||
async def legacy_add_series(
|
||||
request_data: Dict[str, Any],
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Legacy endpoint for adding series - basic implementation."""
|
||||
try:
|
||||
link = request_data.get('link', '')
|
||||
name = request_data.get('name', '')
|
||||
|
||||
if not link or not name:
|
||||
return {"status": "error", "message": "Link and name are required"}
|
||||
|
||||
return {"status": "success", "message": f"Series '{name}' added successfully"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"Failed to add series: {str(e)}"}
|
||||
|
||||
|
||||
@app.post("/api/download")
|
||||
async def legacy_download(
|
||||
request_data: Dict[str, Any],
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Legacy endpoint for downloading series - basic implementation."""
|
||||
try:
|
||||
folders = request_data.get('folders', [])
|
||||
|
||||
if not folders:
|
||||
return {"status": "error", "message": "No folders specified"}
|
||||
|
||||
folder_count = len(folders)
|
||||
return {"status": "success", "message": f"Download started for {folder_count} series"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"Failed to start download: {str(e)}"}
|
||||
|
||||
# Setup endpoints moved to controllers: src/server/controllers/setup_controller.py
|
||||
|
||||
# Health check endpoint (kept in main app)
|
||||
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
||||
async def health_check() -> HealthResponse:
|
||||
"""
|
||||
Application health check endpoint.
|
||||
"""
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.utcnow(),
|
||||
services={
|
||||
"authentication": "online",
|
||||
"anime_service": "online",
|
||||
"episode_service": "online"
|
||||
}
|
||||
)
|
||||
|
||||
# Common browser requests that might cause "Invalid HTTP request received" warnings
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
"""Handle favicon requests from browsers."""
|
||||
return JSONResponse(status_code=404, content={"detail": "Favicon not found"})
|
||||
|
||||
@app.get("/robots.txt")
|
||||
async def robots():
|
||||
"""Handle robots.txt requests."""
|
||||
return JSONResponse(status_code=404, content={"detail": "Robots.txt not found"})
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint redirect to docs."""
|
||||
return {"message": "AniWorld API", "documentation": "/docs", "health": "/health"}
|
||||
|
||||
# Web interface routes
|
||||
@app.get("/app", response_class=HTMLResponse)
|
||||
async def web_app(request: Request):
|
||||
"""Serve the main web application."""
|
||||
return templates.TemplateResponse("base/index.html", {"request": request})
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
"""Serve the login page."""
|
||||
return templates.TemplateResponse("base/login.html", {"request": request})
|
||||
|
||||
@app.get("/setup", response_class=HTMLResponse)
|
||||
async def setup_page(request: Request):
|
||||
"""Serve the setup page."""
|
||||
return templates.TemplateResponse("base/setup.html", {"request": request})
|
||||
|
||||
@app.get("/queue", response_class=HTMLResponse)
|
||||
async def queue_page(request: Request):
|
||||
"""Serve the queue page."""
|
||||
return templates.TemplateResponse("base/queue.html", {"request": request})
|
||||
|
||||
# Anime endpoints (protected)
|
||||
@app.get("/api/anime/search", response_model=List[AnimeResponse], tags=["Anime"])
|
||||
async def search_anime(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> List[AnimeResponse]:
|
||||
"""
|
||||
Search for anime by title.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **query**: Search query string
|
||||
- **limit**: Maximum number of results (1-100)
|
||||
- **offset**: Number of results to skip for pagination
|
||||
"""
|
||||
# TODO: Implement actual anime search logic
|
||||
# This is a placeholder implementation
|
||||
logger.info(f"Searching anime with query: {query}")
|
||||
|
||||
# Mock data for now
|
||||
mock_results = [
|
||||
AnimeResponse(
|
||||
id=f"anime_{i}",
|
||||
title=f"Sample Anime {i}",
|
||||
description=f"Description for anime {i}",
|
||||
episodes=24,
|
||||
status="Completed"
|
||||
)
|
||||
for i in range(offset + 1, min(offset + limit + 1, 100))
|
||||
if query.lower() in f"sample anime {i}".lower()
|
||||
]
|
||||
|
||||
return mock_results
|
||||
|
||||
@app.get("/api/anime/{anime_id}", response_model=AnimeResponse, tags=["Anime"])
|
||||
async def get_anime(
|
||||
anime_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> AnimeResponse:
|
||||
"""
|
||||
Get detailed information about a specific anime.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **anime_id**: Unique identifier for the anime
|
||||
"""
|
||||
# TODO: Implement actual anime retrieval logic
|
||||
logger.info(f"Fetching anime details for ID: {anime_id}")
|
||||
|
||||
# Mock data for now
|
||||
return AnimeResponse(
|
||||
id=anime_id,
|
||||
title=f"Anime {anime_id}",
|
||||
description=f"Detailed description for anime {anime_id}",
|
||||
episodes=24,
|
||||
status="Completed"
|
||||
)
|
||||
|
||||
@app.get("/api/anime/{anime_id}/episodes", response_model=List[EpisodeResponse], tags=["Episodes"])
|
||||
async def get_anime_episodes(
|
||||
anime_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> List[EpisodeResponse]:
|
||||
"""
|
||||
Get all episodes for a specific anime.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **anime_id**: Unique identifier for the anime
|
||||
"""
|
||||
# TODO: Implement actual episode retrieval logic
|
||||
logger.info(f"Fetching episodes for anime ID: {anime_id}")
|
||||
|
||||
# Mock data for now
|
||||
return [
|
||||
EpisodeResponse(
|
||||
id=f"{anime_id}_ep_{i}",
|
||||
anime_id=anime_id,
|
||||
episode_number=i,
|
||||
title=f"Episode {i}",
|
||||
description=f"Description for episode {i}",
|
||||
duration=1440 # 24 minutes in seconds
|
||||
)
|
||||
for i in range(1, 25) # 24 episodes
|
||||
]
|
||||
|
||||
@app.get("/api/episodes/{episode_id}", response_model=EpisodeResponse, tags=["Episodes"])
|
||||
async def get_episode(
|
||||
episode_id: str,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
) -> EpisodeResponse:
|
||||
"""
|
||||
Get detailed information about a specific episode.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
- **episode_id**: Unique identifier for the episode
|
||||
"""
|
||||
# TODO: Implement actual episode retrieval logic
|
||||
logger.info(f"Fetching episode details for ID: {episode_id}")
|
||||
|
||||
# Mock data for now
|
||||
return EpisodeResponse(
|
||||
id=episode_id,
|
||||
anime_id="sample_anime",
|
||||
episode_number=1,
|
||||
title=f"Episode {episode_id}",
|
||||
description=f"Detailed description for episode {episode_id}",
|
||||
duration=1440
|
||||
)
|
||||
|
||||
# Database health check endpoint
|
||||
@app.get("/api/system/database/health", response_model=Dict[str, Any], tags=["System"])
|
||||
async def database_health(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Check database connectivity and health.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
# TODO: Implement actual database health check
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connection_pool": "active",
|
||||
"response_time_ms": 15,
|
||||
"last_check": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Configuration endpoint
|
||||
@app.get("/api/system/config", response_model=Dict[str, Any], tags=["System"])
|
||||
async def get_system_config(current_user: Dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Get system configuration information.
|
||||
|
||||
Requires: Bearer token in Authorization header
|
||||
"""
|
||||
return {
|
||||
"anime_directory": settings.anime_directory,
|
||||
"log_level": settings.log_level,
|
||||
"token_expiry_hours": settings.token_expiry_hours,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import socket
|
||||
|
||||
# Configure enhanced logging
|
||||
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(log_level)
|
||||
|
||||
# Check if port is available
|
||||
def is_port_available(host: str, port: int) -> bool:
|
||||
"""Check if a port is available on the given host."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
|
||||
if not is_port_available(host, port):
|
||||
logger.error(f"Port {port} is already in use on {host}. Please stop other services or choose a different port.")
|
||||
logger.info("You can check which process is using the port with: netstat -ano | findstr :8000")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Starting AniWorld FastAPI server with uvicorn...")
|
||||
logger.info(f"Anime directory: {settings.anime_directory}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
logger.info(f"Server will be available at http://{host}:{port}")
|
||||
logger.info(f"API documentation at http://{host}:{port}/docs")
|
||||
|
||||
try:
|
||||
# Run the application
|
||||
uvicorn.run(
|
||||
"fastapi_app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=False, # Disable reload to prevent constant restarting
|
||||
log_level=settings.log_level.lower()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start server: {e}")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user