Files
anime-qize/backend/app/openings_downloader/router.py

232 lines
7.6 KiB
Python
Raw Normal View History

2026-01-10 11:06:45 +03:00
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import Optional
# Lock to prevent multiple workers running simultaneously
_worker_lock = asyncio.Lock()
_worker_running = False
from ..database import get_db
from .schemas import (
SearchResponse,
AnimeDetailResponse,
ThemeInfo,
AddToQueueRequest,
AddAllThemesRequest,
QueueStatusResponse,
StorageStatsResponse,
)
from .db_models import Anime, AnimeTheme, DownloadTask, DownloadStatus
from .services.shikimori import ShikimoriService
from .services.animethemes import AnimeThemesService
from .services.downloader import DownloadService
from .services.storage_tracker import StorageTrackerService
router = APIRouter(prefix="/downloader", tags=["openings-downloader"])
# ============== Search ==============
@router.get("/search", response_model=SearchResponse)
async def search_anime(
query: str = Query(..., min_length=1, description="Search query"),
year: Optional[int] = Query(None, description="Filter by year"),
status: Optional[str] = Query(None, description="Filter by status (ongoing, released, announced)"),
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
db: AsyncSession = Depends(get_db),
):
"""Search anime via Shikimori API."""
service = ShikimoriService()
results = await service.search(query, year=year, status=status, limit=limit)
return SearchResponse(results=results, total=len(results))
# ============== Anime Detail ==============
@router.get("/anime/{shikimori_id}", response_model=AnimeDetailResponse)
async def get_anime_detail(
shikimori_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get anime details with available themes from AnimeThemes."""
shikimori_service = ShikimoriService()
animethemes_service = AnimeThemesService()
# Get or create anime record
anime = await shikimori_service.get_or_create_anime(db, shikimori_id)
# Fetch themes from AnimeThemes API
themes = await animethemes_service.fetch_themes(db, anime)
# Get download status for each theme
theme_ids = [t.id for t in themes]
if theme_ids:
result = await db.execute(
select(DownloadTask)
.where(DownloadTask.theme_id.in_(theme_ids))
)
tasks = {t.theme_id: t for t in result.scalars().all()}
else:
tasks = {}
# Build response
theme_infos = []
for theme in themes:
task = tasks.get(theme.id)
theme_infos.append(ThemeInfo(
id=theme.id,
theme_type=theme.theme_type,
sequence=theme.sequence,
full_name=theme.full_name,
song_title=theme.song_title,
artist=theme.artist,
video_url=theme.animethemes_video_url,
is_downloaded=theme.is_downloaded,
download_status=task.status if task else None,
file_size_bytes=theme.file_size_bytes,
))
return AnimeDetailResponse(
id=anime.id,
shikimori_id=anime.shikimori_id,
title_russian=anime.title_russian,
title_english=anime.title_english,
title_japanese=anime.title_japanese,
year=anime.year,
poster_url=anime.poster_url,
themes=theme_infos,
)
# ============== Download Queue ==============
@router.post("/queue/add", response_model=QueueStatusResponse)
async def add_to_queue(
request: AddToQueueRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Add specific themes to download queue."""
storage_service = StorageTrackerService(db)
# Check storage limit
stats = await storage_service.get_stats()
if not stats.can_download:
raise HTTPException(
status_code=400,
detail=f"Storage limit exceeded ({stats.used_bytes}/{stats.limit_bytes} bytes)"
)
download_service = DownloadService(db)
added = await download_service.add_to_queue(request.theme_ids)
if added > 0:
# Trigger worker in background
background_tasks.add_task(_run_download_worker)
return await download_service.get_queue_status(worker_running=_worker_running)
@router.post("/queue/add-all", response_model=QueueStatusResponse)
async def add_all_anime_themes(
request: AddAllThemesRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Add all themes from an anime to download queue."""
storage_service = StorageTrackerService(db)
stats = await storage_service.get_stats()
if not stats.can_download:
raise HTTPException(status_code=400, detail="Storage limit exceeded")
download_service = DownloadService(db)
added = await download_service.add_all_anime_themes(request.anime_id)
if added > 0:
background_tasks.add_task(_run_download_worker)
return await download_service.get_queue_status(worker_running=_worker_running)
@router.get("/queue", response_model=QueueStatusResponse)
async def get_queue_status(db: AsyncSession = Depends(get_db)):
"""Get current download queue status."""
download_service = DownloadService(db)
return await download_service.get_queue_status(worker_running=_worker_running)
@router.delete("/queue/{task_id}")
async def cancel_task(task_id: int, db: AsyncSession = Depends(get_db)):
"""Cancel a queued task (not downloading)."""
download_service = DownloadService(db)
success = await download_service.cancel_task(task_id)
if not success:
raise HTTPException(status_code=400, detail="Cannot cancel task (not queued or not found)")
return {"message": "Task cancelled"}
@router.post("/queue/{task_id}/retry")
async def retry_task(
task_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Retry a failed task."""
download_service = DownloadService(db)
success = await download_service.retry_task(task_id)
if not success:
raise HTTPException(status_code=400, detail="Cannot retry task (not failed or not found)")
background_tasks.add_task(_run_download_worker)
return {"message": "Task queued for retry"}
@router.delete("/queue/clear")
async def clear_completed_tasks(
include_failed: bool = Query(False, description="Also clear failed tasks"),
db: AsyncSession = Depends(get_db),
):
"""Clear completed (and optionally failed) tasks from the queue."""
download_service = DownloadService(db)
deleted_count = await download_service.clear_completed_tasks(include_failed=include_failed)
return {"message": f"Cleared {deleted_count} tasks", "deleted_count": deleted_count}
# ============== Storage ==============
@router.get("/storage", response_model=StorageStatsResponse)
async def get_storage_stats(db: AsyncSession = Depends(get_db)):
"""Get S3 storage usage stats (calculated from DB, not scanning S3)."""
storage_service = StorageTrackerService(db)
return await storage_service.get_stats()
# ============== Background Worker ==============
async def _run_download_worker():
"""Background task to process download queue."""
global _worker_running
# Skip if another worker is already running
if _worker_running:
return
async with _worker_lock:
if _worker_running:
return
_worker_running = True
try:
from ..database import async_session_maker
async with async_session_maker() as db:
download_service = DownloadService(db)
await download_service.process_queue()
finally:
_worker_running = False