This commit is contained in:
2025-10-22 13:38:46 +02:00
parent 1f39f07c5d
commit 04799633b4
9 changed files with 411 additions and 571 deletions

View File

@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@@ -24,40 +24,76 @@ class AnimeDetail(BaseModel):
@router.get("/", response_model=List[AnimeSummary])
async def list_anime(
_auth: dict = Depends(require_auth),
series_app=Depends(get_series_app)
):
"""List series with missing episodes using the core SeriesApp."""
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""List library series that still have missing episodes.
Args:
_auth: Ensures the caller is authenticated (value unused).
series_app: Core `SeriesApp` instance provided via dependency.
Returns:
List[AnimeSummary]: Summary entries describing missing content.
Raises:
HTTPException: When the underlying lookup fails.
"""
try:
series = series_app.List.GetMissingEpisode()
result = []
for s in series:
missing = 0
try:
missing = len(s.episodeDict) if getattr(s, "episodeDict", None) is not None else 0
except Exception:
missing = 0
result.append(AnimeSummary(id=getattr(s, "key", getattr(s, "folder", "")), title=getattr(s, "name", ""), missing_episodes=missing))
return result
summaries: List[AnimeSummary] = []
for serie in series:
episodes_dict = getattr(serie, "episodeDict", {}) or {}
missing_episodes = len(episodes_dict)
key = getattr(serie, "key", getattr(serie, "folder", ""))
title = getattr(serie, "name", "")
summaries.append(
AnimeSummary(
id=key,
title=title,
missing_episodes=missing_episodes,
)
)
return summaries
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve anime list")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve anime list",
) from exc
@router.post("/rescan")
async def trigger_rescan(series_app=Depends(get_series_app)):
"""Trigger a rescan of local series data using SeriesApp.ReScan."""
async def trigger_rescan(series_app: Any = Depends(get_series_app)) -> dict:
"""Kick off a background rescan of the local library.
Args:
series_app: Core `SeriesApp` instance provided via dependency.
Returns:
Dict[str, Any]: Status payload communicating whether the rescan
launched successfully.
Raises:
HTTPException: If the rescan command is unsupported or fails.
"""
try:
# SeriesApp.ReScan expects a callback; pass a no-op
if hasattr(series_app, "ReScan"):
series_app.ReScan(lambda *args, **kwargs: None)
return {"success": True, "message": "Rescan started"}
else:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Rescan not available")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Rescan not available",
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start rescan")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start rescan",
) from exc
class SearchRequest(BaseModel):
@@ -65,56 +101,107 @@ class SearchRequest(BaseModel):
@router.post("/search", response_model=List[AnimeSummary])
async def search_anime(request: SearchRequest, series_app=Depends(get_series_app)):
"""Search for new anime by query text using the SeriesApp loader."""
async def search_anime(
request: SearchRequest,
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""Search the provider for additional series matching a query.
Args:
request: Incoming payload containing the search term.
series_app: Core `SeriesApp` instance provided via dependency.
Returns:
List[AnimeSummary]: Discovered matches returned from the provider.
Raises:
HTTPException: When provider communication fails.
"""
try:
matches = []
matches: List[Any] = []
if hasattr(series_app, "search"):
# SeriesApp.search is synchronous in core; call directly
matches = series_app.search(request.query)
result = []
for m in matches:
# matches may be dicts or objects
if isinstance(m, dict):
mid = m.get("key") or m.get("id") or ""
title = m.get("title") or m.get("name") or ""
missing = int(m.get("missing", 0)) if m.get("missing") is not None else 0
summaries: List[AnimeSummary] = []
for match in matches:
if isinstance(match, dict):
identifier = match.get("key") or match.get("id") or ""
title = match.get("title") or match.get("name") or ""
missing = match.get("missing")
missing_episodes = int(missing) if missing is not None else 0
else:
mid = getattr(m, "key", getattr(m, "id", ""))
title = getattr(m, "title", getattr(m, "name", ""))
missing = int(getattr(m, "missing", 0))
result.append(AnimeSummary(id=mid, title=title, missing_episodes=missing))
identifier = getattr(match, "key", getattr(match, "id", ""))
title = getattr(match, "title", getattr(match, "name", ""))
missing_episodes = int(getattr(match, "missing", 0))
return result
summaries.append(
AnimeSummary(
id=identifier,
title=title,
missing_episodes=missing_episodes,
)
)
return summaries
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Search failed")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Search failed",
) from exc
@router.get("/{anime_id}", response_model=AnimeDetail)
async def get_anime(anime_id: str, series_app=Depends(get_series_app)):
"""Return detailed info about a series from SeriesApp.List."""
async def get_anime(
anime_id: str,
series_app: Any = Depends(get_series_app)
) -> AnimeDetail:
"""Return detailed information about a specific series.
Args:
anime_id: Provider key or folder name of the requested series.
series_app: Core `SeriesApp` instance provided via dependency.
Returns:
AnimeDetail: Detailed series metadata including episode list.
Raises:
HTTPException: If the anime cannot be located or retrieval fails.
"""
try:
series = series_app.List.GetList()
found = None
for s in series:
if getattr(s, "key", None) == anime_id or getattr(s, "folder", None) == anime_id:
found = s
for serie in series:
matches_key = getattr(serie, "key", None) == anime_id
matches_folder = getattr(serie, "folder", None) == anime_id
if matches_key or matches_folder:
found = serie
break
if not found:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Series not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Series not found",
)
episodes = []
epdict = getattr(found, "episodeDict", {}) or {}
for season, eps in epdict.items():
for e in eps:
episodes.append(f"{season}-{e}")
episodes: List[str] = []
episode_dict = getattr(found, "episodeDict", {}) or {}
for season, episode_numbers in episode_dict.items():
for episode in episode_numbers:
episodes.append(f"{season}-{episode}")
return AnimeDetail(id=getattr(found, "key", getattr(found, "folder", "")), title=getattr(found, "name", ""), episodes=episodes, description=getattr(found, "description", None))
return AnimeDetail(
id=getattr(found, "key", getattr(found, "folder", "")),
title=getattr(found, "name", ""),
episodes=episodes,
description=getattr(found, "description", None),
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve series details")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve series details",
) from exc

View File

@@ -5,7 +5,7 @@ This module provides dependency injection functions for the FastAPI
application, including SeriesApp instances, AnimeService, DownloadService,
database sessions, and authentication dependencies.
"""
from typing import AsyncGenerator, Optional
from typing import TYPE_CHECKING, AsyncGenerator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -19,6 +19,10 @@ from src.config.settings import settings
from src.core.SeriesApp import SeriesApp
from src.server.services.auth_service import AuthError, auth_service
if TYPE_CHECKING:
from src.server.services.anime_service import AnimeService
from src.server.services.download_service import DownloadService
# Security scheme for JWT authentication
# Use auto_error=False to handle errors manually and return 401 instead of 403
security = HTTPBearer(auto_error=False)
@@ -28,8 +32,8 @@ security = HTTPBearer(auto_error=False)
_series_app: Optional[SeriesApp] = None
# Global service instances
_anime_service: Optional[object] = None
_download_service: Optional[object] = None
_anime_service: Optional["AnimeService"] = None
_download_service: Optional["DownloadService"] = None
def get_series_app() -> SeriesApp:
@@ -193,7 +197,13 @@ def get_current_user_optional(
class CommonQueryParams:
"""Common query parameters for API endpoints."""
def __init__(self, skip: int = 0, limit: int = 100):
def __init__(self, skip: int = 0, limit: int = 100) -> None:
"""Create a reusable pagination parameter container.
Args:
skip: Number of records to offset when querying collections.
limit: Maximum number of records to return in a single call.
"""
self.skip = skip
self.limit = limit
@@ -235,7 +245,7 @@ async def log_request_dependency():
pass
def get_anime_service() -> object:
def get_anime_service() -> "AnimeService":
"""
Dependency to get AnimeService instance.
@@ -257,29 +267,39 @@ def get_anime_service() -> object:
import sys
import tempfile
running_tests = "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules
running_tests = (
"PYTEST_CURRENT_TEST" in os.environ
or "pytest" in sys.modules
)
if running_tests:
settings.anime_directory = tempfile.gettempdir()
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Anime directory not configured. Please complete setup.",
detail=(
"Anime directory not configured. "
"Please complete setup."
),
)
if _anime_service is None:
try:
from src.server.services.anime_service import AnimeService
_anime_service = AnimeService(settings.anime_directory)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initialize AnimeService: {str(e)}",
detail=(
"Failed to initialize AnimeService: "
f"{str(e)}"
),
) from e
return _anime_service
def get_download_service() -> object:
def get_download_service() -> "DownloadService":
"""
Dependency to get DownloadService instance.
@@ -293,46 +313,49 @@ def get_download_service() -> object:
if _download_service is None:
try:
from src.server.services import (
websocket_service as websocket_service_module,
)
from src.server.services.download_service import DownloadService
from src.server.services.websocket_service import get_websocket_service
# Get anime service first (required dependency)
anime_service = get_anime_service()
# Initialize download service with anime service
_download_service = DownloadService(anime_service)
# Setup WebSocket broadcast callback
ws_service = get_websocket_service()
async def broadcast_callback(update_type: str, data: dict):
ws_service = websocket_service_module.get_websocket_service()
async def broadcast_callback(update_type: str, data: dict) -> None:
"""Broadcast download updates via WebSocket."""
if update_type == "download_progress":
await ws_service.broadcast_download_progress(
data.get("download_id", ""), data
data.get("download_id", ""),
data,
)
elif update_type == "download_complete":
await ws_service.broadcast_download_complete(
data.get("download_id", ""), data
data.get("download_id", ""),
data,
)
elif update_type == "download_failed":
await ws_service.broadcast_download_failed(
data.get("download_id", ""), data
data.get("download_id", ""),
data,
)
elif update_type == "queue_status":
await ws_service.broadcast_queue_status(data)
else:
# Generic queue update
await ws_service.broadcast_queue_status(data)
_download_service.set_broadcast_callback(broadcast_callback)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to initialize DownloadService: {str(e)}",
detail=(
"Failed to initialize DownloadService: "
f"{str(e)}"
),
) from e
return _download_service