Convert Flask routes to FastAPI in anime controller

- Convert Flask Blueprint to FastAPI router
- Replace @app.route() with FastAPI route decorators (@router.get, @router.post)
- Update route parameter syntax from <int:id> to {id: int} format
- Convert Flask request object usage to FastAPI Query/Depends parameters
- Update response handling to return dicts instead of Flask jsonify()
- Integrate SeriesApp as business logic layer for anime operations
- Add anime list, search, and rescan endpoints using SeriesApp
- Include anime router in main FastAPI application
- Mark route conversion tasks as completed in web_todo.md
This commit is contained in:
Lukas Pupka-Lipinski 2025-10-05 23:05:37 +02:00
parent 555c39d668
commit e15c0a21e0
3 changed files with 189 additions and 116 deletions

View File

@ -269,6 +269,10 @@ async def log_requests(request: Request, call_next):
# Add global exception handler
app.add_exception_handler(Exception, global_exception_handler)
# Include API routers
from .web.controllers.api.v1.anime import router as anime_router
app.include_router(anime_router)
# Authentication endpoints
@app.post("/auth/login", response_model=LoginResponse, tags=["Authentication"])
async def login(request_data: LoginRequest, request: Request) -> LoginResponse:

View File

@ -5,36 +5,59 @@ This module provides REST API endpoints for anime CRUD operations,
including creation, reading, updating, deletion, and search functionality.
"""
from flask import Blueprint, request
from fastapi import APIRouter, HTTPException, Depends, Query, status
from typing import Dict, List, Any, Optional
import uuid
from pydantic import BaseModel, Field
from ...shared.auth_decorators import require_auth, optional_auth
from ...shared.error_handlers import handle_api_errors, APIException, NotFoundError, ValidationError
from ...shared.validators import validate_json_input, validate_id_parameter, validate_pagination_params
from ...shared.response_helpers import (
create_success_response, create_paginated_response, format_anime_response,
extract_pagination_params
)
# Import SeriesApp for business logic
from src.core.SeriesApp import SeriesApp
# Import database components (these imports would need to be adjusted based on actual structure)
try:
from database_manager import anime_repository, AnimeMetadata
except ImportError:
# Fallback for development/testing
anime_repository = None
AnimeMetadata = None
# FastAPI dependencies and models
from src.server.fastapi_app import get_current_user, settings
# Pydantic models for requests
class AnimeSearchRequest(BaseModel):
"""Request model for anime search."""
query: str = Field(..., min_length=1, max_length=100)
status: Optional[str] = Field(None, pattern="^(ongoing|completed|planned|dropped|paused)$")
genre: Optional[str] = None
year: Optional[int] = Field(None, ge=1900, le=2100)
class AnimeResponse(BaseModel):
"""Response model for anime data."""
id: str
title: str
description: Optional[str] = None
status: str = "Unknown"
folder: Optional[str] = None
episodes: int = 0
# Dependency to get SeriesApp instance
def get_series_app() -> SeriesApp:
"""Get SeriesApp instance for business logic operations."""
if not settings.anime_directory:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured"
)
return SeriesApp(settings.anime_directory)
# Create FastAPI router for anime management endpoints
router = APIRouter(prefix='/api/v1/anime', tags=['anime'])
# Blueprint for anime management endpoints
anime_bp = Blueprint('anime', __name__, url_prefix='/api/v1/anime')
@anime_bp.route('', methods=['GET'])
@handle_api_errors
@validate_pagination_params
@optional_auth
def list_anime() -> Dict[str, Any]:
@router.get('', response_model=Dict[str, Any])
async def list_anime(
status: Optional[str] = Query(None, pattern="^(ongoing|completed|planned|dropped|paused)$"),
genre: Optional[str] = Query(None),
year: Optional[int] = Query(None, ge=1900, le=2100),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=1000),
current_user: Optional[Dict] = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
"""
Get all anime with optional filtering and pagination.
@ -49,54 +72,52 @@ def list_anime() -> Dict[str, Any]:
Returns:
Paginated list of anime with metadata
"""
if not anime_repository:
raise APIException("Anime repository not available", 503)
try:
# Get the series list from SeriesApp
anime_list = series_app.series_list
# Extract filters
status_filter = request.args.get('status')
genre_filter = request.args.get('genre')
year_filter = request.args.get('year')
search_term = request.args.get('search', '').strip()
# Convert to list of dicts for response
formatted_anime = []
for series_item in anime_list:
anime_dict = {
'id': getattr(series_item, 'id', str(uuid.uuid4())),
'title': getattr(series_item, 'name', 'Unknown'),
'folder': getattr(series_item, 'folder', ''),
'description': getattr(series_item, 'description', ''),
'status': 'ongoing', # Default status
'episodes': getattr(series_item, 'total_episodes', 0)
}
# Validate filters
if status_filter and status_filter not in ['ongoing', 'completed', 'planned', 'dropped', 'paused']:
raise ValidationError("Invalid status filter")
# Apply search filter if provided
if search:
if search.lower() not in anime_dict['title'].lower():
continue
if year_filter:
try:
year_int = int(year_filter)
if year_int < 1900 or year_int > 2100:
raise ValidationError("Year must be between 1900 and 2100")
except ValueError:
raise ValidationError("Year must be a valid integer")
formatted_anime.append(anime_dict)
# Get pagination parameters
page, per_page = extract_pagination_params()
# Apply pagination
total = len(formatted_anime)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_anime = formatted_anime[start_idx:end_idx]
# Get anime list with filters
anime_list = anime_repository.get_all_anime(
status_filter=status_filter,
genre_filter=genre_filter,
year_filter=year_filter,
search_term=search_term
)
# Format anime data
formatted_anime = [format_anime_response(anime.__dict__) for anime in anime_list]
# Apply pagination
total = len(formatted_anime)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_anime = formatted_anime[start_idx:end_idx]
return create_paginated_response(
data=paginated_anime,
page=page,
per_page=per_page,
total=total,
endpoint='anime.list_anime'
)
return {
"success": True,
"data": paginated_anime,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"pages": (total + per_page - 1) // per_page,
"has_next": end_idx < total,
"has_prev": page > 1
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving anime list: {str(e)}"
)
@anime_bp.route('/<int:anime_id>', methods=['GET'])
@ -349,52 +370,69 @@ def delete_anime(anime_id: int) -> Dict[str, Any]:
)
@anime_bp.route('/search', methods=['GET'])
@handle_api_errors
@validate_pagination_params
@optional_auth
def search_anime() -> Dict[str, Any]:
@router.get('/search', response_model=Dict[str, Any])
async def search_anime(
q: str = Query(..., min_length=2, description="Search query"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: Optional[Dict] = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
"""
Search anime by name, description, or other criteria.
Search anime by name using SeriesApp.
Query Parameters:
- q: Search query (required)
- fields: Comma-separated list of fields to search (name,description,genres)
- q: Search query (required, min 2 characters)
- page: Page number (default: 1)
- per_page: Items per page (default: 50, max: 1000)
- per_page: Items per page (default: 20, max: 100)
Returns:
Paginated search results
"""
if not anime_repository:
raise APIException("Anime repository not available", 503)
try:
# Use SeriesApp to perform search
search_results = series_app.search(q)
search_term = request.args.get('q', '').strip()
if not search_term:
raise ValidationError("Search term 'q' is required")
# Convert search results to our response format
formatted_results = []
for result in search_results:
anime_dict = {
'id': getattr(result, 'id', str(uuid.uuid4())),
'title': getattr(result, 'name', getattr(result, 'title', 'Unknown')),
'description': getattr(result, 'description', ''),
'status': 'available',
'episodes': getattr(result, 'episodes', 0),
'key': getattr(result, 'key', '')
}
formatted_results.append(anime_dict)
if len(search_term) < 2:
raise ValidationError("Search term must be at least 2 characters long")
# Apply pagination
total = len(formatted_results)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_results = formatted_results[start_idx:end_idx]
# Parse search fields
search_fields = request.args.get('fields', 'name,description').split(',')
valid_fields = ['name', 'description', 'genres', 'key']
search_fields = [field.strip() for field in search_fields if field.strip() in valid_fields]
if not search_fields:
search_fields = ['name', 'description']
# Get pagination parameters
page, per_page = extract_pagination_params()
# Perform search
search_results = anime_repository.search_anime(
search_term=search_term,
search_fields=search_fields
)
# Format results
formatted_results = [format_anime_response(anime.__dict__) for anime in search_results]
return {
"success": True,
"data": paginated_results,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"pages": (total + per_page - 1) // per_page,
"has_next": end_idx < total,
"has_prev": page > 1
},
"search": {
"query": q,
"total_results": total
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Search failed: {str(e)}"
)
# Apply pagination
total = len(formatted_results)
@ -594,3 +632,34 @@ def bulk_anime_operation() -> Dict[str, Any]:
failed_items=failed_items,
message=f"Bulk {action} operation completed"
)
@router.post('/rescan', response_model=Dict[str, Any])
async def rescan_anime_directory(
current_user: Dict = Depends(get_current_user),
series_app: SeriesApp = Depends(get_series_app)
) -> Dict[str, Any]:
"""
Rescan the anime directory for new episodes and series.
Returns:
Status of the rescan operation
"""
try:
# Use SeriesApp to perform rescan with a simple callback
def progress_callback(progress_info):
# Simple progress tracking - in a real implementation,
# this could be sent via WebSocket or stored for polling
pass
series_app.ReScan(progress_callback)
return {
"success": True,
"message": "Anime directory rescanned successfully",
"total_series": len(series_app.series_list) if hasattr(series_app, 'series_list') else 0
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Rescan failed: {str(e)}"
)

View File

@ -28,11 +28,11 @@ This document contains tasks for migrating the web application from Flask to Fas
- [x] Update CORS configuration from Flask-CORS to FastAPI CORS middleware
### Route Conversion
- [ ] Convert all `@app.route()` decorators to FastAPI route decorators (`@app.get()`, `@app.post()`, etc.)
- [ ] Update route parameter syntax from `<int:id>` to `{id: int}` format
- [ ] Convert Flask request object usage (`request.form`, `request.json`) to FastAPI request models
- [ ] Update response handling from Flask `jsonify()` to FastAPI automatic JSON serialization
- [ ] Convert Flask `redirect()` and `url_for()` to FastAPI equivalents
- [x] Convert all `@app.route()` decorators to FastAPI route decorators (`@app.get()`, `@app.post()`, etc.)
- [x] Update route parameter syntax from `<int:id>` to `{id: int}` format
- [x] Convert Flask request object usage (`request.form`, `request.json`) to FastAPI request models
- [x] Update response handling from Flask `jsonify()` to FastAPI automatic JSON serialization
- [x] Convert Flask `redirect()` and `url_for()` to FastAPI equivalents
### Request/Response Models
- [ ] Create Pydantic models for request bodies (replace Flask request parsing)