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,13 +1,12 @@
import logging
import os
import sys
import time
from typing import Any, Callable, Mapping, Optional, Sequence
from rich.progress import Progress
from ..core.entities import SerieList
from ..core.entities.series import Serie
from ..core.providers import aniworld_provider
from ..core.providers.provider_factory import Loaders
from ..core.SerieScanner import SerieScanner
@@ -30,35 +29,37 @@ for h in logging.getLogger().handlers:
class NoKeyFoundException(Exception):
"""Exception raised when an anime key cannot be found."""
pass
class MatchNotFoundError(Exception):
"""Exception raised when an anime key cannot be found."""
pass
class SeriesApp:
_initialization_count = 0 # Track how many times initialization has been called
_initialization_count = 0 # Track initialization calls
def __init__(self, directory_to_search: str):
def __init__(self, directory_to_search: str) -> None:
SeriesApp._initialization_count += 1
# Only show initialization message for the first instance
if SeriesApp._initialization_count <= 1:
print("Please wait while initializing...")
self.progress = None
self.progress: Optional[Progress] = None
self.directory_to_search = directory_to_search
self.Loaders = Loaders()
self.Loaders: Loaders = Loaders()
loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(directory_to_search, loader)
self.List = SerieList(self.directory_to_search)
self.__init_list__()
def __init_list__(self):
def __init_list__(self) -> None:
"""Initialize the series list by fetching missing episodes."""
self.series_list = self.List.GetMissingEpisode()
self.series_list: Sequence[Serie] = self.List.GetMissingEpisode()
def display_series(self):
def display_series(self) -> None:
"""Print all series with assigned numbers."""
print("\nCurrent result:")
for i, serie in enumerate(self.series_list, 1):
@@ -68,12 +69,12 @@ class SeriesApp:
else:
print(f"{i}. {serie.name}")
def search(self, words: str) -> list:
def search(self, words: str) -> list[dict[str, Any]]:
"""Search for anime series by name."""
loader = self.Loaders.GetLoader(key="aniworld.to")
return loader.search(words)
def get_user_selection(self):
def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Handle user input for selecting series."""
self.display_series()
while True:
@@ -86,9 +87,9 @@ class SeriesApp:
if selection == "exit":
return None
selected_series = []
selected_series: list[Serie] = []
if selection == "all":
selected_series = self.series_list
selected_series = list(self.series_list)
else:
try:
indexes = [
@@ -118,7 +119,14 @@ class SeriesApp:
print(msg)
return None
def retry(self, func, max_retries=3, delay=2, *args, **kwargs):
def retry(
self,
func: Callable[..., Any],
max_retries: int = 3,
delay: float = 2,
*args: Any,
**kwargs: Any,
) -> bool:
"""Retry a function with exponential backoff.
Args:
@@ -140,7 +148,7 @@ class SeriesApp:
time.sleep(delay)
return False
def download_series(self, series):
def download_series(self, series: Sequence[Serie]) -> None:
"""Simulate the downloading process with a progress bar."""
total_downloaded = 0
total_episodes = sum(
@@ -182,7 +190,7 @@ class SeriesApp:
episode,
serie.key,
"German Dub",
self.print_Download_Progress,
self.print_download_progress,
)
downloaded += 1
@@ -195,20 +203,24 @@ class SeriesApp:
self.progress.stop()
self.progress = None
def print_download_progress(self, d):
def print_download_progress(self, d: Mapping[str, Any]) -> None:
"""Update download progress in the UI.
Args:
d: Dictionary containing download status information
"""
# Use self.progress and self.download_progress_task to display progress
if (self.progress is None or
not hasattr(self, "download_progress_task")):
if (
self.progress is None
or not hasattr(self, "download_progress_task")
):
return
if d["status"] == "downloading":
total = (d.get("total_bytes") or
d.get("total_bytes_estimate"))
total = (
d.get("total_bytes")
or d.get("total_bytes_estimate")
)
downloaded = d.get("downloaded_bytes", 0)
if total:
percent = downloaded / total * 100
@@ -232,7 +244,7 @@ class SeriesApp:
description=desc
)
def search_mode(self):
def search_mode(self) -> None:
"""Search for a series and allow user to select an option."""
search_string = input("Enter search string: ").strip()
results = self.search(search_string)
@@ -272,10 +284,10 @@ class SeriesApp:
except ValueError:
print("Invalid input. Try again.")
def updateFromReinit(self, folder, counter):
def updateFromReinit(self, folder: str, counter: int) -> None:
self.progress.update(self.task1, advance=1)
def run(self):
def run(self) -> None:
"""Main function to run the app."""
while True:
prompt = (

View File

@@ -10,7 +10,7 @@ import os
import re
import traceback
import uuid
from typing import Callable, Optional
from typing import Callable, Iterable, Iterator, Optional
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
@@ -40,7 +40,7 @@ class SerieScanner:
basePath: str,
loader: Loader,
callback_manager: Optional[CallbackManager] = None
):
) -> None:
"""
Initialize the SerieScanner.
@@ -49,10 +49,12 @@ class SerieScanner:
loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates
"""
self.directory = basePath
self.directory: str = basePath
self.folderDict: dict[str, Serie] = {}
self.loader = loader
self._callback_manager = callback_manager or CallbackManager()
self.loader: Loader = loader
self._callback_manager: CallbackManager = (
callback_manager or CallbackManager()
)
self._current_operation_id: Optional[str] = None
logger.info("Initialized SerieScanner with base path: %s", basePath)
@@ -62,22 +64,22 @@ class SerieScanner:
"""Get the callback manager instance."""
return self._callback_manager
def reinit(self):
def reinit(self) -> None:
"""Reinitialize the folder dictionary."""
self.folderDict: dict[str, Serie] = {}
def is_null_or_whitespace(self, s):
def is_null_or_whitespace(self, value: Optional[str]) -> bool:
"""Check if a string is None or whitespace.
Args:
s: String value to check
value: String value to check
Returns:
True if string is None or contains only whitespace
"""
return s is None or s.strip() == ""
return value is None or value.strip() == ""
def get_total_to_scan(self):
def get_total_to_scan(self) -> int:
"""Get the total number of folders to scan.
Returns:
@@ -86,7 +88,10 @@ class SerieScanner:
result = self.__find_mp4_files()
return sum(1 for _ in result)
def scan(self, callback: Optional[Callable[[str, int], None]] = None):
def scan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> None:
"""
Scan directories for anime series and missing episodes.
@@ -127,10 +132,10 @@ class SerieScanner:
counter += 1
# Calculate progress
percentage = (
(counter / total_to_scan * 100)
if total_to_scan > 0 else 0
)
if total_to_scan > 0:
percentage = (counter / total_to_scan) * 100
else:
percentage = 0.0
# Notify progress
self._callback_manager.notify_progress(
@@ -262,13 +267,13 @@ class SerieScanner:
raise
def __find_mp4_files(self):
def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]:
"""Find all .mp4 files in the directory structure."""
logger.info("Scanning for .mp4 files")
for anime_name in os.listdir(self.directory):
anime_path = os.path.join(self.directory, anime_name)
if os.path.isdir(anime_path):
mp4_files = []
mp4_files: list[str] = []
has_files = False
for root, _, files in os.walk(anime_path):
for file in files:
@@ -277,7 +282,7 @@ class SerieScanner:
has_files = True
yield anime_name, mp4_files if has_files else []
def __remove_year(self, input_string: str):
def __remove_year(self, input_string: str) -> str:
"""Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logger.debug(
@@ -287,7 +292,7 @@ class SerieScanner:
)
return cleaned_string
def __read_data_from_file(self, folder_name: str):
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
"""Read serie data from file or key file.
Args:
@@ -322,7 +327,7 @@ class SerieScanner:
return None
def __get_episode_and_season(self, filename: str):
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
"""Extract season and episode numbers from filename.
Args:
@@ -355,7 +360,10 @@ class SerieScanner:
"Season and episode pattern not found in the filename."
)
def __get_episodes_and_seasons(self, mp4_files: list):
def __get_episodes_and_seasons(
self,
mp4_files: Iterable[str]
) -> dict[int, list[int]]:
"""Get episodes grouped by season from mp4 files.
Args:
@@ -364,7 +372,7 @@ class SerieScanner:
Returns:
Dictionary mapping season to list of episode numbers
"""
episodes_dict = {}
episodes_dict: dict[int, list[int]] = {}
for file in mp4_files:
season, episode = self.__get_episode_and_season(file)
@@ -375,7 +383,11 @@ class SerieScanner:
episodes_dict[season] = [episode]
return episodes_dict
def __get_missing_episodes_and_season(self, key: str, mp4_files: list):
def __get_missing_episodes_and_season(
self,
key: str,
mp4_files: Iterable[str]
) -> tuple[dict[int, list[int]], str]:
"""Get missing episodes for a serie.
Args:
@@ -388,7 +400,7 @@ class SerieScanner:
# key season , value count of episodes
expected_dict = self.loader.get_season_episode_count(key)
filedict = self.__get_episodes_and_seasons(mp4_files)
episodes_dict = {}
episodes_dict: dict[int, list[int]] = {}
for season, expected_count in expected_dict.items():
existing_episodes = filedict.get(season, [])
missing_episodes = [

View File

@@ -27,38 +27,74 @@ noKeyFound_logger = logging.getLogger("NoKeyFound")
noKeyFound_handler = logging.FileHandler("../../NoKeyFound.log")
noKeyFound_handler.setLevel(logging.ERROR)
class AniworldLoader(Loader):
def __init__(self):
self.SUPPORTED_PROVIDERS = ["VOE", "Doodstream", "Vidmoly", "Vidoza", "SpeedFiles", "Streamtape", "Luluvdo"]
self.SUPPORTED_PROVIDERS = [
"VOE",
"Doodstream",
"Vidmoly",
"Vidoza",
"SpeedFiles",
"Streamtape",
"Luluvdo",
]
self.AniworldHeaders = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
"cache-control": "max-age=0",
"priority": "u=0, i",
"sec-ch-ua": '"Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
}
self.INVALID_PATH_CHARS = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '&']
"accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": (
"de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"
),
"cache-control": "max-age=0",
"priority": "u=0, i",
"sec-ch-ua": (
'"Chromium";v="136", "Microsoft Edge";v="136", '
'"Not.A/Brand";v="99"'
),
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
),
}
self.INVALID_PATH_CHARS = [
"<",
">",
":",
'"',
"/",
"\\",
"|",
"?",
"*",
"&",
]
self.RANDOM_USER_AGENT = UserAgent().random
self.LULUVDO_USER_AGENT = "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0"
self.LULUVDO_USER_AGENT = (
"Mozilla/5.0 (Android 15; Mobile; rv:132.0) "
"Gecko/132.0 Firefox/132.0"
)
self.PROVIDER_HEADERS = {
"Vidmoly": ['Referer: "https://vidmoly.to"'],
"Doodstream": ['Referer: "https://dood.li/"'],
"VOE": [f'User-Agent: {self.RANDOM_USER_AGENT}'],
"Luluvdo": [
f'User-Agent: {self.LULUVDO_USER_AGENT}',
'Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Origin: "https://luluvdo.com"',
'Referer: "https://luluvdo.com/"'
]}
"Vidmoly": ['Referer: "https://vidmoly.to"'],
"Doodstream": ['Referer: "https://dood.li/"'],
"VOE": [f"User-Agent: {self.RANDOM_USER_AGENT}"],
"Luluvdo": [
f"User-Agent: {self.LULUVDO_USER_AGENT}",
"Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
'Origin: "https://luluvdo.com"',
'Referer: "https://luluvdo.com/"',
],
}
self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session()
@@ -66,7 +102,7 @@ class AniworldLoader(Loader):
retries = Retry(
total=5, # Number of retries
backoff_factor=1, # Delay multiplier (1s, 2s, 4s, ...)
status_forcelist=[500, 502, 503, 504], # Retry for specific HTTP errors
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET"]
)
@@ -96,12 +132,13 @@ class AniworldLoader(Loader):
Returns:
List of found series
"""
search_url = f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
search_url = (
f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
)
anime_list = self.fetch_anime_list(search_url)
return anime_list
def fetch_anime_list(self, url: str) -> list:
response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
@@ -297,7 +334,7 @@ class AniworldLoader(Loader):
self._get_episode_html(season, episode, key).content,
'html.parser'
)
providers = {}
providers: dict[str, dict[int, str]] = {}
episode_links = soup.find_all(
'li', class_=lambda x: x and x.startswith('episodeLink')
@@ -390,7 +427,7 @@ class AniworldLoader(Loader):
"VOE"
).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT)
def get_season_episode_count(self, slug : str) -> dict:
def get_season_episode_count(self, slug: str) -> dict:
base_url = f"{self.ANIWORLD_TO}/anime/stream/{slug}/"
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
soup = BeautifulSoup(response.content, 'html.parser')
@@ -402,7 +439,10 @@ class AniworldLoader(Loader):
for season in range(1, number_of_seasons + 1):
season_url = f"{base_url}staffel-{season}"
response = requests.get(season_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
response = requests.get(
season_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT,
)
soup = BeautifulSoup(response.content, 'html.parser')
episode_links = soup.find_all('a', href=True)

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