From 0c753a26c177970eaec1e2c99c9914a53451083f Mon Sep 17 00:00:00 2001 From: endercodezz Date: Sun, 11 Oct 2026 21:26:57 +0300 Subject: [PATCH 1/5] Backend 2.0: offline library integrity, provider engine, secure media proxy, download manager - Library is derived only from downloaded files that exist and validate; browsing a title no longer creates folders or library entries. Pure path helpers, library index service, rescan. - Versioned SQLite migrations (PRAGMA user_version) with automatic backups, rollback, id canonicalisation and duplicate merging, library-relative paths, unique constraints. - Provider engine: per-stage timeouts and typed errors, isolated per-source HTTP sessions without anti-bot retries, player fallback per voiceover, AnimeGO CVH adapter, exact cache recovery, health from real operations and explicit checks. HDRezka removed. - Media proxy uses server-issued session tokens with SSRF checks on every hop; shared network path (proxy) for resolution, playback and downloads. - Download manager: concurrency, real ffmpeg progress and speed, Range resume for direct files, validation, atomic finalisation, retries, restart recovery, part-file cleanup. - API split into modules; Host header protection, log redaction, WAL checkpoint on shutdown. - Pinned anicli-api commit; LF line endings for patches. --- .gitattributes | 3 + .gitignore | 5 +- backend/__init__.py | 2 + backend/api/__init__.py | 8 +- backend/api/catalog.py | 146 ++++ backend/api/common.py | 30 + backend/api/downloads.py | 129 ++++ backend/api/history.py | 154 +++++ backend/api/library.py | 88 +++ backend/api/routes.py | 383 ---------- backend/api/system.py | 96 +++ backend/config.py | 26 +- backend/database/migrations.py | 291 ++++++++ backend/database/session.py | 31 + backend/downloader/manager.py | 754 +++++++++++++++----- backend/main.py | 119 +++- backend/models/__init__.py | 48 +- backend/player/proxy.py | 186 ++--- backend/player/sessions.py | 163 +++++ backend/providers/__init__.py | 25 +- backend/providers/cache.py | 122 ++++ backend/providers/catalog.py | 98 +++ backend/providers/errors.py | 55 ++ backend/providers/health.py | 100 +++ backend/providers/http.py | 148 ++++ backend/providers/ids.py | 38 + backend/providers/players.py | 218 ++++++ backend/providers/registry.py | 883 +++++++++++++++--------- backend/schemas/__init__.py | 230 ++++-- backend/services/ffmpeg_tools.py | 243 +++++-- backend/services/library.py | 308 +++++++-- backend/services/library_index.py | 323 +++++++++ backend/services/settings.py | 117 ++-- frontend/public/icons.svg | 24 - frontend/src/assets/hero.png | Bin 13057 -> 0 bytes frontend/src/assets/react.svg | 1 - frontend/src/assets/vite.svg | 1 - frontend/src/components/AnimeCard.tsx | 53 -- frontend/src/components/Layout.tsx | 111 --- frontend/src/components/ProgressBar.tsx | 16 - patches/anicli-api.ref | 1 + pytest.ini | 6 + tests/conftest.py | 80 +++ tests/fakes.py | 73 ++ tests/test_api.py | 148 ++++ tests/test_api_routes.py | 48 -- tests/test_downloads.py | 298 ++++++++ tests/test_ffmpeg.py | 166 +++++ tests/test_library.py | 165 +++++ tests/test_media_pipeline.py | 202 ------ tests/test_media_proxy.py | 166 +++++ tests/test_migrations.py | 174 +++++ tests/test_portable_main.py | 25 + tests/test_provider_registry.py | 298 ++++++-- 54 files changed, 5890 insertions(+), 1736 deletions(-) create mode 100644 backend/api/catalog.py create mode 100644 backend/api/common.py create mode 100644 backend/api/downloads.py create mode 100644 backend/api/history.py create mode 100644 backend/api/library.py delete mode 100644 backend/api/routes.py create mode 100644 backend/api/system.py create mode 100644 backend/database/migrations.py create mode 100644 backend/player/sessions.py create mode 100644 backend/providers/cache.py create mode 100644 backend/providers/catalog.py create mode 100644 backend/providers/errors.py create mode 100644 backend/providers/health.py create mode 100644 backend/providers/http.py create mode 100644 backend/providers/ids.py create mode 100644 backend/providers/players.py create mode 100644 backend/services/library_index.py delete mode 100644 frontend/public/icons.svg delete mode 100644 frontend/src/assets/hero.png delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/assets/vite.svg delete mode 100644 frontend/src/components/AnimeCard.tsx delete mode 100644 frontend/src/components/Layout.tsx delete mode 100644 frontend/src/components/ProgressBar.tsx create mode 100644 patches/anicli-api.ref create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/fakes.py create mode 100644 tests/test_api.py delete mode 100644 tests/test_api_routes.py create mode 100644 tests/test_downloads.py create mode 100644 tests/test_ffmpeg.py create mode 100644 tests/test_library.py delete mode 100644 tests/test_media_pipeline.py create mode 100644 tests/test_media_proxy.py create mode 100644 tests/test_migrations.py diff --git a/.gitattributes b/.gitattributes index dfe0770..ad5956e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Auto detect text files and perform LF normalization * text=auto +# Patches are applied with git apply against LF checkouts; keep them LF on every OS. +*.patch text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 04cf3f0..a9cc993 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,9 @@ desktop.ini *.swp *.swo -# Claude local state +# Local AI-agent workspace and temporary development files +.tmp/ .claude/ +.codex/ +.cursor/ claude.md diff --git a/backend/__init__.py b/backend/__init__.py index cae5476..29ca2df 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1 +1,3 @@ """AnimeBox backend package.""" + +__version__ = "2.0.0" diff --git a/backend/api/__init__.py b/backend/api/__init__.py index c98b31d..1c5e2d3 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -1,3 +1,9 @@ -from backend.api.routes import router +from fastapi import APIRouter + +from backend.api import catalog, downloads, history, library, system + +router = APIRouter() +for module in (system, catalog, downloads, library, history): + router.include_router(module.router) __all__ = ["router"] diff --git a/backend/api/catalog.py b/backend/api/catalog.py new file mode 100644 index 0000000..4bb1ede --- /dev/null +++ b/backend/api/catalog.py @@ -0,0 +1,146 @@ +"""Search, title details, voiceovers, stream resolution, provider diagnostics.""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.common import anime_id_param, provider_failure +from backend.config import get_settings +from backend.database.session import get_db +from backend.player.sessions import media_sessions +from backend.providers import get_registry +from backend.providers.catalog import SOURCE_BY_ID, SOURCES +from backend.providers.errors import ProviderError, ProviderUnavailable +from backend.schemas import ( + AnimeDetail, + ProviderInfo, + SearchResponse, + StageOut, + StreamResolveRequest, + StreamResolveResponse, + VoiceoverOption, +) +from backend.services import library_index + +router = APIRouter(prefix="/api") +CHECK_TIMEOUT = 150 + + +def _provider_info(source: str) -> ProviderInfo: + registry = get_registry() + spec = SOURCE_BY_ID[source] + health = registry.health.get(source) + return ProviderInfo( + id=spec.id, + label=spec.label, + stable=spec.stable, + enabled=spec.id in registry.enabled_sources, + is_default=spec.id == registry.default_source, + note=spec.note, + status=health.status, # type: ignore[arg-type] + stages={name: StageOut(**vars(result)) for name, result in health.stages.items()}, + last_check_at=health.last_check_at, + ) + + +@router.get("/providers", response_model=list[ProviderInfo]) +async def list_providers(): + return [_provider_info(spec.id) for spec in get_registry().ordered_sources()] + + +@router.post("/providers/{source}/check", response_model=ProviderInfo) +async def check_provider(source: str): + if source not in SOURCE_BY_ID: + raise HTTPException(404, {"code": "unknown_source", "message": f"Unknown source '{source}'"}) + try: + await asyncio.wait_for(get_registry().check(source), timeout=CHECK_TIMEOUT) + except TimeoutError: + pass # stage timeouts were recorded individually + return _provider_info(source) + + +@router.get("/search", response_model=SearchResponse) +async def search( + q: str = Query(..., min_length=1, max_length=200), + source: str | None = Query(None, max_length=64), +): + registry = get_registry() + if source and source not in SOURCE_BY_ID: + raise HTTPException(400, {"code": "unknown_source", "message": f"Unknown source '{source}'"}) + try: + return await registry.search(q, sources=[source] if source else None) + except ProviderUnavailable as exc: + provider_failure(exc, "Search") + + +@router.get("/anime", response_model=AnimeDetail) +async def anime_detail(id: str = Query(..., min_length=3, max_length=1024), db: AsyncSession = Depends(get_db)): + anime_id = anime_id_param(id) + registry = get_registry() + try: + detail = await registry.get_anime(anime_id) + except Exception as exc: # noqa: BLE001 + provider_failure(exc, "Loading title") + # Catalog metadata only. Library membership is decided by downloaded files, never here. + await library_index.upsert_title( + db, + anime_id=anime_id, + source=detail.source, + title=detail.title, + poster=detail.poster, + description=detail.description, + year=detail.year, + ) + await db.commit() + return detail + + +@router.get("/anime/voiceovers", response_model=list[VoiceoverOption]) +async def episode_voiceovers( + id: str = Query(..., min_length=3, max_length=1024), + episode: int = Query(..., ge=0, le=100000), +): + anime_id = anime_id_param(id) + try: + return await get_registry().get_episode_voiceovers(anime_id, episode) + except Exception as exc: # noqa: BLE001 + provider_failure(exc, "Loading voiceovers") + + +@router.post("/stream/resolve", response_model=StreamResolveResponse) +async def resolve_stream(body: StreamResolveRequest): + anime_id = anime_id_param(body.anime_id) + registry = get_registry() + try: + stream = await registry.resolve_stream( + anime_id, + body.episode, + voiceover=body.voiceover, + quality=body.quality, + player=body.player, + purpose="play", + refresh=body.refresh, + ) + except ProviderError as exc: + provider_failure(exc, "Resolving stream") + except Exception as exc: # noqa: BLE001 + provider_failure(exc, "Resolving stream") + session = media_sessions.create(stream.video.headers, registry.proxy, ttl=get_settings().media_session_ttl) + return StreamResolveResponse( + anime_title=stream.anime_title, + episode=stream.episode, + voiceover=stream.voiceover, + player=stream.player, + type=stream.video.type, + quality=stream.video.quality, + qualities=stream.qualities, + url=session.url_for(stream.video.url), + browser_playable=stream.browser_playable, + expires_at=session.expires_at, + ) + + +__all__ = ["router", "SOURCES"] diff --git a/backend/api/common.py b/backend/api/common.py new file mode 100644 index 0000000..17a52aa --- /dev/null +++ b/backend/api/common.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import logging +from typing import NoReturn + +from fastapi import HTTPException + +from backend.providers.errors import ProviderError +from backend.providers.ids import canonical_id, safe_canonical_id + +logger = logging.getLogger("animebox.api") + + +def anime_id_param(value: str) -> str: + """Canonical provider id or HTTP 400.""" + try: + return canonical_id(value) + except ValueError as exc: + raise HTTPException(400, {"code": "invalid_id", "message": str(exc)}) from exc + + +def library_id_param(value: str) -> str: + return safe_canonical_id(value) + + +def provider_failure(exc: Exception, action: str) -> NoReturn: + if isinstance(exc, ProviderError): + raise HTTPException(exc.http_status, exc.to_detail()) from exc + logger.exception("%s failed", action) + raise HTTPException(502, {"code": "internal_error", "message": f"{action} failed: {type(exc).__name__}"}) from exc diff --git a/backend/api/downloads.py b/backend/api/downloads.py new file mode 100644 index 0000000..e4d6f5d --- /dev/null +++ b/backend/api/downloads.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.common import anime_id_param, provider_failure +from backend.database.session import get_db +from backend.downloader.manager import download_manager +from backend.models import OPEN_DOWNLOAD_STATUSES, DownloadJob +from backend.providers import decode_external_id, get_registry +from backend.schemas import ApiMessage, DownloadCreateResponse, DownloadJobOut, DownloadRequest, SkippedDownload +from backend.services import library_index +from backend.services.settings import get_app_settings + +router = APIRouter(prefix="/api/downloads") + + +@router.post("", response_model=DownloadCreateResponse) +async def create_downloads(body: DownloadRequest, db: AsyncSession = Depends(get_db)): + anime_id = anime_id_param(body.anime_id) + try: + detail = await get_registry().get_anime(anime_id) + except Exception as exc: # noqa: BLE001 + provider_failure(exc, "Loading title") + + known = {e.ordinal: e for e in detail.episodes} + if body.episodes: + wanted = list(dict.fromkeys(body.episodes)) + elif body.episode is not None: + wanted = [body.episode] + else: + wanted = list(known) + missing = [n for n in wanted if n not in known] + if missing: + raise HTTPException(404, {"code": "not_found", "message": f"Episodes not found: {missing[:10]}"}) + if not wanted: + raise HTTPException(400, {"code": "no_episodes", "message": "No episodes to download"}) + + await library_index.upsert_title( + db, + anime_id=anime_id, + source=detail.source, + title=detail.title, + poster=detail.poster, + description=detail.description, + year=detail.year, + ) + await db.commit() + + settings = await get_app_settings(db) + steam_deck = settings.steam_deck_optimize if body.steam_deck is None else body.steam_deck + source, _ = decode_external_id(anime_id) + jobs: list[DownloadJob] = [] + skipped: list[SkippedDownload] = [] + for number in wanted: + job, reason = await download_manager.enqueue( + anime_id=anime_id, + anime_title=detail.title, + source=source, + season=body.season, + episode=number, + episode_title=known[number].title, + voiceover=body.voiceover, + steam_deck=steam_deck, + quality=body.quality, + replace=body.replace, + ) + if job is not None: + jobs.append(job) + else: + skipped.append(SkippedDownload(episode=number, reason=reason)) # type: ignore[arg-type] + return DownloadCreateResponse(jobs=[DownloadJobOut.model_validate(j) for j in jobs], skipped=skipped) + + +@router.get("", response_model=list[DownloadJobOut]) +async def list_downloads( + status: Literal["all", "active", "finished"] = "all", + limit: int = Query(300, ge=1, le=2000), + db: AsyncSession = Depends(get_db), +): + query = select(DownloadJob) + if status == "active": + query = query.where(DownloadJob.status.in_(OPEN_DOWNLOAD_STATUSES)) + elif status == "finished": + query = query.where(DownloadJob.status.not_in(OPEN_DOWNLOAD_STATUSES)) + rows = (await db.execute(query.order_by(DownloadJob.id.desc()).limit(limit))).scalars().all() + return rows + + +def _command(ok: bool, message: str, failure: str) -> ApiMessage: + if not ok: + raise HTTPException(409, {"code": "invalid_state", "message": failure}) + return ApiMessage(message=message) + + +@router.post("/{job_id}/cancel", response_model=ApiMessage) +async def cancel_download(job_id: int): + return _command(await download_manager.cancel(job_id), "cancelled", "Job not found or already finished") + + +@router.post("/{job_id}/retry", response_model=ApiMessage) +async def retry_download(job_id: int): + return _command(await download_manager.retry(job_id), "requeued", "Only failed or cancelled jobs can be retried") + + +@router.post("/{job_id}/pause", response_model=ApiMessage) +async def pause_download(job_id: int): + return _command( + await download_manager.pause(job_id), "paused", "Only active direct-file downloads support pausing" + ) + + +@router.post("/{job_id}/resume", response_model=ApiMessage) +async def resume_download(job_id: int): + return _command(await download_manager.resume(job_id), "resumed", "Job is not paused") + + +@router.delete("/{job_id}", response_model=ApiMessage) +async def remove_download(job_id: int): + return _command(await download_manager.remove(job_id), "removed", "Active jobs cannot be removed; cancel first") + + +@router.post("/clear", response_model=ApiMessage) +async def clear_finished(): + removed = await download_manager.clear_finished() + return ApiMessage(message=f"removed {removed} finished jobs", detail={"removed": removed}) diff --git a/backend/api/history.py b/backend/api/history.py new file mode 100644 index 0000000..c62fce9 --- /dev/null +++ b/backend/api/history.py @@ -0,0 +1,154 @@ +"""Playback progress, watch history and the home screen feed.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.common import library_id_param +from backend.database.session import get_db +from backend.models import AnimeTitle, LibraryEpisode, WatchHistory +from backend.schemas import ApiMessage, HomeOut, ProgressUpdate, WatchHistoryOut +from backend.services import library, library_index + +router = APIRouter(prefix="/api") +COMPLETED_RATIO = 0.9 + + +def _completed(progress: float, duration: float | None) -> bool: + return bool(duration and duration > 0 and progress / duration >= COMPLETED_RATIO) + + +async def _decorate(db: AsyncSession, rows: list[WatchHistory]) -> list[WatchHistoryOut]: + ids = list({row.anime_external_id for row in rows}) + titles = {} + if ids: + titles = { + t.external_id: t + for t in (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id.in_(ids)))).scalars().all() + } + out: list[WatchHistoryOut] = [] + local_poster_cache: dict[str, bool] = {} + for row in rows: + item = WatchHistoryOut.model_validate(row) + item.completed = _completed(row.progress_seconds, row.duration_seconds) + title = titles.get(row.anime_external_id) + if row.anime_external_id not in local_poster_cache: + folder = library.resolve_stored_path(title.folder_path) if title and title.folder_path else None + local_poster_cache[row.anime_external_id] = bool(folder and library.poster_file(folder)) + if local_poster_cache[row.anime_external_id]: + item.poster = library_index.poster_endpoint(row.anime_external_id) + elif not item.poster and title: + item.poster = title.poster + out.append(item) + return out + + +@router.post("/progress", response_model=WatchHistoryOut) +async def save_progress(body: ProgressUpdate, db: AsyncSession = Depends(get_db)): + anime_id = library_id_param(body.anime_id) if body.anime_id else None + local_id = body.local_episode_id + if local_id: + ep = await db.get(LibraryEpisode, local_id) + if ep is None: + local_id = None + else: + ep.progress_seconds = body.progress_seconds + if body.duration_seconds: + ep.duration_seconds = body.duration_seconds + if _completed(body.progress_seconds, body.duration_seconds): + ep.completed = True + anime_id = anime_id or ep.anime_external_id + if not anime_id: + raise HTTPException(400, {"code": "missing_anime_id", "message": "anime_id required"}) + + row = ( + await db.execute( + select(WatchHistory).where( + WatchHistory.anime_external_id == anime_id, + WatchHistory.season == body.season, + WatchHistory.episode == body.episode, + ) + ) + ).scalar_one_or_none() + if row is None: + row = WatchHistory( + anime_external_id=anime_id, + anime_title=body.anime_title or anime_id, + season=body.season, + episode=body.episode, + ) + db.add(row) + row.progress_seconds = body.progress_seconds + if body.duration_seconds: + row.duration_seconds = body.duration_seconds + row.local_episode_id = local_id or row.local_episode_id + if body.anime_title: + row.anime_title = body.anime_title + if body.poster and not body.poster.startswith("/api/"): + row.poster = body.poster + if body.voiceover: + row.voiceover = body.voiceover + await db.commit() + await db.refresh(row) + return (await _decorate(db, [row]))[0] + + +@router.get("/history", response_model=list[WatchHistoryOut]) +async def history(limit: int = Query(60, ge=1, le=500), db: AsyncSession = Depends(get_db)): + rows = ( + await db.execute(select(WatchHistory).order_by(WatchHistory.updated_at.desc(), WatchHistory.id.desc()).limit(limit)) + ).scalars().all() + return await _decorate(db, list(rows)) + + +@router.get("/history/anime", response_model=list[WatchHistoryOut]) +async def anime_history(id: str = Query(..., max_length=1024), db: AsyncSession = Depends(get_db)): + rows = ( + await db.execute( + select(WatchHistory) + .where(WatchHistory.anime_external_id == library_id_param(id)) + .order_by(WatchHistory.season, WatchHistory.episode) + ) + ).scalars().all() + return await _decorate(db, list(rows)) + + +@router.delete("/history/{entry_id}", response_model=ApiMessage) +async def delete_history_entry(entry_id: int, db: AsyncSession = Depends(get_db)): + row = await db.get(WatchHistory, entry_id) + if row is None: + raise HTTPException(404, {"code": "not_found", "message": "History entry not found"}) + await db.delete(row) + await db.commit() + return ApiMessage(message="deleted") + + +@router.delete("/history", response_model=ApiMessage) +async def clear_history(db: AsyncSession = Depends(get_db)): + result = await db.execute(delete(WatchHistory)) + await db.commit() + return ApiMessage(message="cleared", detail={"deleted": result.rowcount}) + + +@router.get("/home", response_model=HomeOut) +async def home(db: AsyncSession = Depends(get_db)): + rows = ( + await db.execute(select(WatchHistory).order_by(WatchHistory.updated_at.desc(), WatchHistory.id.desc()).limit(200)) + ).scalars().all() + latest_per_anime: list[WatchHistory] = [] + seen: set[str] = set() + for row in rows: + if row.anime_external_id in seen: + continue + seen.add(row.anime_external_id) + latest_per_anime.append(row) + decorated = await _decorate(db, latest_per_anime[:24]) + continue_watching = [item for item in decorated if not item.completed and item.progress_seconds >= 5][:12] + library_items = await library_index.list_library(db) + return HomeOut( + continue_watching=continue_watching, + recently_watched=decorated[:12], + recently_downloaded=library_items[:12], + ) diff --git a/backend/api/library.py b/backend/api/library.py new file mode 100644 index 0000000..68e6402 --- /dev/null +++ b/backend/api/library.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import mimetypes + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.common import library_id_param +from backend.database.session import get_db +from backend.models import AnimeTitle, LibraryEpisode +from backend.schemas import ApiMessage, LibraryAnimeOut, LibraryEpisodeOut, LibraryStats, RescanResult +from backend.services import library, library_index + +router = APIRouter(prefix="/api/library") + +mimetypes.add_type("video/x-matroska", ".mkv") +mimetypes.add_type("video/mp4", ".m4v") + + +@router.get("", response_model=list[LibraryAnimeOut]) +async def list_library(db: AsyncSession = Depends(get_db)): + return await library_index.list_library(db) + + +@router.get("/stats", response_model=LibraryStats) +async def library_stats(db: AsyncSession = Depends(get_db)): + return await library_index.stats(db) + + +@router.get("/episodes", response_model=list[LibraryEpisodeOut]) +async def library_episodes(id: str = Query(..., max_length=1024), db: AsyncSession = Depends(get_db)): + return await library_index.library_episodes(db, library_id_param(id)) + + +@router.get("/file/{episode_id}") +async def library_file(episode_id: int, db: AsyncSession = Depends(get_db)): + row = await db.get(LibraryEpisode, episode_id) + path = library.resolve_stored_path(row.file_path) if row else None + if path is None or not library.is_valid_media_file(path): + raise HTTPException(404, {"code": "file_missing", "message": "The downloaded file is missing"}) + media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + return FileResponse(path, media_type=media_type, content_disposition_type="inline") + + +@router.get("/poster") +async def library_poster(id: str = Query(..., max_length=1024), db: AsyncSession = Depends(get_db)): + anime_id = library_id_param(id) + title = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() + episodes = ( + await db.execute(select(LibraryEpisode).where(LibraryEpisode.anime_external_id == anime_id).limit(5)) + ).scalars().all() + anime_dir = library_index.anime_dir_for(title, list(episodes)) + poster = library.poster_file(anime_dir) if anime_dir else None + if poster is None: + raise HTTPException(404, {"code": "poster_missing", "message": "Poster not found"}) + with poster.open("rb") as handle: + detected = library.image_type(handle.read(16)) + return FileResponse( + poster, + media_type=detected[0] if detected else mimetypes.guess_type(poster.name)[0] or "image/jpeg", + headers={"Cache-Control": "private, max-age=86400"}, + ) + + +@router.delete("/anime", response_model=ApiMessage) +async def delete_anime(id: str = Query(..., max_length=1024), db: AsyncSession = Depends(get_db)): + deleted = await library_index.delete_anime(db, library_id_param(id)) + if not deleted: + exists = ( + await db.execute(select(LibraryEpisode.id).where(LibraryEpisode.anime_external_id == library_id_param(id))) + ).first() + if exists is None: + raise HTTPException(404, {"code": "not_found", "message": "Title is not in the library"}) + return ApiMessage(message=f"deleted {deleted} files", detail={"deleted": deleted}) + + +@router.delete("/episodes/{episode_id}", response_model=ApiMessage) +async def delete_episode(episode_id: int, db: AsyncSession = Depends(get_db)): + if not await library_index.delete_episode(db, episode_id): + raise HTTPException(404, {"code": "not_found", "message": "Episode is not in the library"}) + return ApiMessage(message="deleted") + + +@router.post("/rescan", response_model=RescanResult) +async def rescan(db: AsyncSession = Depends(get_db)): + return await library_index.rescan(db) diff --git a/backend/api/routes.py b/backend/api/routes.py deleted file mode 100644 index f3ddf8e..0000000 --- a/backend/api/routes.py +++ /dev/null @@ -1,383 +0,0 @@ -from __future__ import annotations - -import logging -import mimetypes -from pathlib import Path -from urllib.parse import quote - -from fastapi import APIRouter, Depends, Header, HTTPException, Query -from fastapi.responses import FileResponse -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from backend.database.session import get_db -from backend.downloader.manager import download_manager -from backend.models import AnimeTitle, DownloadJob, LibraryEpisode, WatchHistory -from backend.player.proxy import store_headers -from backend.providers import decode_external_id, get_registry -from backend.schemas import ( - AnimeDetail, - ApiMessage, - AppSettingsOut, - AppSettingsUpdate, - DownloadJobOut, - DownloadRequest, - EpisodeInfo, - LibraryAnimeOut, - LibraryEpisodeOut, - ProgressUpdate, - SearchResult, - SourceInfo, - StreamResolveRequest, - StreamResolveResponse, - VoiceoverOption, - WatchHistoryOut, -) -from backend.services import library -from backend.services.settings import get_app_settings, update_app_settings - -logger = logging.getLogger("animebox.api") -router = APIRouter(prefix="/api") - - -@router.get("/health") -async def health(): - return {"status": "ok"} - - -@router.get("/session") -async def session(): - from backend.main import SHUTDOWN_TOKEN, shutdown_enabled - - return {"shutdown_token": SHUTDOWN_TOKEN, "shutdown_enabled": shutdown_enabled()} - - -@router.post("/shutdown", response_model=ApiMessage) -async def shutdown(x_animebox_token: str = Header(...)): - from backend.main import request_shutdown - - if not request_shutdown(x_animebox_token): - raise HTTPException(403, "Invalid shutdown token") - return ApiMessage(message="AnimeBox is shutting down") - - -@router.get("/sources", response_model=list[SourceInfo]) -async def list_sources(): - reg = get_registry() - return [SourceInfo(id=s, label=s) for s in reg.available_sources()] - - -@router.get("/search", response_model=list[SearchResult]) -async def search( - q: str = Query(..., min_length=1), - source: str | None = None, -): - reg = get_registry() - sources = [source] if source else None - try: - return await reg.search(q, sources=sources) - except Exception as exc: - logger.exception("search failed") - raise HTTPException(502, f"Search failed: {exc}") from exc - - -@router.get("/anime/{anime_id:path}/episodes/{episode}/voiceovers", response_model=list[VoiceoverOption]) -async def episode_voiceovers(anime_id: str, episode: int): - reg = get_registry() - try: - return await reg.get_episode_voiceovers(anime_id, episode) - except LookupError as exc: - raise HTTPException(404, str(exc)) from exc - except Exception as exc: - raise HTTPException(502, str(exc)) from exc - - -@router.get("/anime/{anime_id:path}", response_model=AnimeDetail) -async def anime_detail(anime_id: str, voiceovers: bool = False, db: AsyncSession = Depends(get_db)): - reg = get_registry() - try: - detail = await reg.get_anime(anime_id, include_voiceovers=voiceovers) - except LookupError as exc: - raise HTTPException(404, str(exc)) from exc - except Exception as exc: - logger.exception("anime detail failed") - raise HTTPException(502, f"Failed to load anime: {exc}") from exc - - # Upsert title metadata - row = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() - if row is None: - db.add( - AnimeTitle( - external_id=anime_id, - source=detail.source, - title=detail.title, - poster=detail.poster, - description=detail.description, - year=detail.year, - folder_path=str(library.anime_dir(detail.title)), - ) - ) - else: - row.title = detail.title - row.poster = detail.poster - row.description = detail.description - row.year = detail.year - await db.commit() - return detail - - -@router.post("/stream/resolve", response_model=StreamResolveResponse) -async def resolve_stream(body: StreamResolveRequest): - reg = get_registry() - try: - stream = await reg.resolve_stream( - body.anime_id, - body.episode, - voiceover=body.voiceover, - quality=body.quality, - ) - except LookupError as exc: - raise HTTPException(404, str(exc)) from exc - except Exception as exc: - logger.exception("resolve stream failed") - raise HTTPException(502, str(exc)) from exc - - hid = store_headers(stream.video.headers) if stream.video.headers else None - from urllib.parse import quote - - proxy_url = f"/api/proxy/stream?url={quote(stream.video.url, safe='')}" - if hid: - proxy_url += f"&hid={hid}" - return StreamResolveResponse( - anime_title=stream.anime_title, - episode=stream.episode, - voiceover=stream.voiceover, - video=stream.video, - proxy_url=proxy_url, - ) - - -@router.post("/downloads", response_model=list[DownloadJobOut]) -async def create_downloads(body: DownloadRequest, db: AsyncSession = Depends(get_db)): - reg = get_registry() - try: - detail = await reg.get_anime(body.anime_id) - except Exception as exc: - raise HTTPException(404, str(exc)) from exc - - source, _ = decode_external_id(body.anime_id) - episodes = body.episodes or ([body.episode] if body.episode is not None else [e.ordinal for e in detail.episodes]) - if not episodes: - raise HTTPException(400, "No episodes to download") - - settings = await get_app_settings(db) - steam = body.steam_deck or settings.steam_deck_optimize - jobs: list[DownloadJob] = [] - for ep_num in episodes: - ep_meta = next((e for e in detail.episodes if e.ordinal == ep_num), None) - job = await download_manager.enqueue( - anime_id=body.anime_id, - anime_title=detail.title, - source=source, - season=body.season, - episode=ep_num, - episode_title=ep_meta.title if ep_meta else f"Episode {ep_num}", - voiceover=body.voiceover, - steam_deck=steam, - ) - jobs.append(job) - return jobs - - -@router.get("/downloads", response_model=list[DownloadJobOut]) -async def list_downloads(db: AsyncSession = Depends(get_db)): - rows = (await db.execute(select(DownloadJob).order_by(DownloadJob.id.desc()).limit(200))).scalars().all() - return rows - - -@router.post("/downloads/{job_id}/cancel", response_model=ApiMessage) -async def cancel_download(job_id: int): - ok = await download_manager.cancel(job_id) - if not ok: - raise HTTPException(404, "Job not found or not cancellable") - return ApiMessage(message="cancelled") - - -@router.post("/downloads/{job_id}/retry", response_model=ApiMessage) -async def retry_download(job_id: int): - ok = await download_manager.retry(job_id) - if not ok: - raise HTTPException(404, "Job not found") - return ApiMessage(message="requeued") - - -@router.get("/library/file/{episode_id}") -async def library_file(episode_id: int, db: AsyncSession = Depends(get_db)): - ep = await db.get(LibraryEpisode, episode_id) - if not ep or not Path(ep.file_path).exists(): - raise HTTPException(404, "File not found") - path = Path(ep.file_path) - media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" - return FileResponse(path, media_type=media_type, content_disposition_type="inline") - - -@router.get("/library/{anime_id:path}/poster") -async def library_poster(anime_id: str, db: AsyncSession = Depends(get_db)): - title = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() - candidates: list[Path] = [] - if title and title.folder_path: - candidates.append(Path(title.folder_path) / "poster.jpg") - first = ( - await db.execute( - select(LibraryEpisode).where(LibraryEpisode.anime_external_id == anime_id).limit(1) - ) - ).scalar_one_or_none() - if first: - candidates.append(Path(first.file_path).parent.parent / "poster.jpg") - poster = next((path for path in candidates if path.is_file()), None) - if not poster: - raise HTTPException(404, "Poster not found") - return FileResponse(poster, media_type=mimetypes.guess_type(poster.name)[0] or "image/jpeg") - - -@router.get("/library", response_model=list[LibraryAnimeOut]) -async def list_library(db: AsyncSession = Depends(get_db)): - titles = (await db.execute(select(AnimeTitle).order_by(AnimeTitle.updated_at.desc()))).scalars().all() - # Also include titles that only exist via downloaded episodes - ep_ids = ( - await db.execute(select(LibraryEpisode.anime_external_id).distinct()) - ).scalars().all() - known = {t.external_id for t in titles} - out: list[LibraryAnimeOut] = [] - for t in titles: - count = ( - await db.execute( - select(func.count()).select_from(LibraryEpisode).where(LibraryEpisode.anime_external_id == t.external_id) - ) - ).scalar_one() - if count == 0 and t.folder_path and not Path(t.folder_path).exists(): - continue - out.append( - LibraryAnimeOut( - id=t.external_id, - title=t.title, - poster=f"/api/library/{quote(t.external_id, safe='')}/poster" - if t.folder_path and (Path(t.folder_path) / "poster.jpg").is_file() - else t.poster, - description=t.description, - year=t.year, - source=t.source, - episode_count=count, - folder_path=t.folder_path, - ) - ) - for eid in ep_ids: - if eid in known: - continue - count = ( - await db.execute( - select(func.count()).select_from(LibraryEpisode).where(LibraryEpisode.anime_external_id == eid) - ) - ).scalar_one() - first = ( - await db.execute(select(LibraryEpisode).where(LibraryEpisode.anime_external_id == eid).limit(1)) - ).scalar_one() - out.append( - LibraryAnimeOut( - id=eid, - title=Path(first.file_path).parts[-3] if len(Path(first.file_path).parts) >= 3 else eid, - poster=None, - description=None, - year=None, - source=decode_external_id(eid)[0] if ":" in eid else "local", - episode_count=count, - folder_path=str(Path(first.file_path).parent.parent), - ) - ) - return out - - -@router.get("/library/{anime_id:path}/episodes", response_model=list[LibraryEpisodeOut]) -async def library_episodes(anime_id: str, db: AsyncSession = Depends(get_db)): - rows = ( - await db.execute( - select(LibraryEpisode) - .where(LibraryEpisode.anime_external_id == anime_id) - .order_by(LibraryEpisode.season, LibraryEpisode.episode) - ) - ).scalars().all() - return rows - - -@router.post("/progress", response_model=WatchHistoryOut) -async def save_progress(body: ProgressUpdate, db: AsyncSession = Depends(get_db)): - anime_id = body.anime_id - if body.local_episode_id: - ep = await db.get(LibraryEpisode, body.local_episode_id) - if ep: - ep.progress_seconds = body.progress_seconds - ep.duration_seconds = body.duration_seconds - if body.duration_seconds and body.progress_seconds / body.duration_seconds > 0.9: - ep.completed = True - anime_id = anime_id or ep.anime_external_id - - if not anime_id: - raise HTTPException(400, "anime_id required") - - row = ( - await db.execute( - select(WatchHistory).where( - WatchHistory.anime_external_id == anime_id, - WatchHistory.season == body.season, - WatchHistory.episode == body.episode, - ) - ) - ).scalar_one_or_none() - if row is None: - row = WatchHistory( - anime_external_id=anime_id, - anime_title=body.anime_title or anime_id, - poster=body.poster, - season=body.season, - episode=body.episode, - progress_seconds=body.progress_seconds, - duration_seconds=body.duration_seconds, - local_episode_id=body.local_episode_id, - ) - db.add(row) - else: - row.progress_seconds = body.progress_seconds - row.duration_seconds = body.duration_seconds - row.local_episode_id = body.local_episode_id or row.local_episode_id - if body.anime_title: - row.anime_title = body.anime_title - if body.poster: - row.poster = body.poster - await db.commit() - await db.refresh(row) - return row - - -@router.get("/history", response_model=list[WatchHistoryOut]) -async def history(db: AsyncSession = Depends(get_db)): - rows = (await db.execute(select(WatchHistory).order_by(WatchHistory.updated_at.desc()).limit(50))).scalars().all() - out: list[WatchHistoryOut] = [] - for row in rows: - item = WatchHistoryOut.model_validate(row) - title = ( - await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == row.anime_external_id)) - ).scalar_one_or_none() - if title and title.folder_path and (Path(title.folder_path) / "poster.jpg").is_file(): - item.poster = f"/api/library/{quote(row.anime_external_id, safe='')}/poster" - out.append(item) - return out - - -@router.get("/settings", response_model=AppSettingsOut) -async def settings_get(db: AsyncSession = Depends(get_db)): - return await get_app_settings(db) - - -@router.put("/settings", response_model=AppSettingsOut) -async def settings_put(body: AppSettingsUpdate, db: AsyncSession = Depends(get_db)): - return await update_app_settings(db, body) diff --git a/backend/api/system.py b/backend/api/system.py new file mode 100644 index 0000000..e6d4efb --- /dev/null +++ b/backend/api/system.py @@ -0,0 +1,96 @@ +"""Health, session/shutdown, settings and diagnostics.""" + +from __future__ import annotations + +import asyncio +import platform +import sys + +from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from backend import __version__ +from backend.config import get_settings +from backend.database.session import get_db +from backend.downloader.manager import download_manager +from backend.player.sessions import media_sessions +from backend.providers.http import redact_proxy +from backend.schemas import ApiMessage, AppSettingsOut, AppSettingsUpdate +from backend.services import ffmpeg_tools, library +from backend.services.settings import get_app_settings, update_app_settings + +router = APIRouter(prefix="/api") + + +@router.get("/health") +async def health(): + return {"status": "ok", "version": __version__} + + +@router.get("/session") +async def session(): + from backend.main import SHUTDOWN_TOKEN, shutdown_enabled + + return {"shutdown_token": SHUTDOWN_TOKEN, "shutdown_enabled": shutdown_enabled(), "version": __version__} + + +@router.post("/shutdown", response_model=ApiMessage) +async def shutdown(x_animebox_token: str = Header(...)): + from backend.main import request_shutdown + + if not request_shutdown(x_animebox_token): + raise HTTPException(403, {"code": "invalid_token", "message": "Invalid shutdown token"}) + return ApiMessage(message="AnimeBox is shutting down") + + +@router.get("/settings", response_model=AppSettingsOut) +async def settings_get(db: AsyncSession = Depends(get_db)): + return await get_app_settings(db) + + +@router.put("/settings", response_model=AppSettingsOut) +async def settings_put(body: AppSettingsUpdate, db: AsyncSession = Depends(get_db)): + try: + return await update_app_settings(db, body) + except ValueError as exc: + raise HTTPException(422, {"code": "invalid_settings", "message": str(exc)}) from exc + + +async def _ffmpeg_version(path: str | None) -> str | None: + if not path: + return None + try: + proc = await asyncio.create_subprocess_exec( + path, "-version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + return out.decode("utf-8", errors="ignore").splitlines()[0] if out else None + except (OSError, TimeoutError): + return None + + +@router.get("/diagnostics") +async def diagnostics(db: AsyncSession = Depends(get_db)): + cfg = get_settings() + app_settings = await get_app_settings(db) + ffmpeg = ffmpeg_tools.ffmpeg_path() + usage = library.disk_usage(cfg.library_path) + return { + "version": __version__, + "python": sys.version.split()[0], + "platform": f"{platform.system()} {platform.release()} ({platform.machine()})", + "frozen": bool(getattr(sys, "frozen", False)), + "ffmpeg": {"path": ffmpeg, "version": await _ffmpeg_version(ffmpeg)}, + "paths": { + "library": str(cfg.library_path), + "data": str(cfg.data_path), + "database": str(cfg.database_file) if cfg.database_file else cfg.database_url, + }, + "disk": usage, + "network": {"proxy": redact_proxy(app_settings.http_proxy)}, + "runtime": { + "media_sessions": len(media_sessions), + "running_downloads": len(download_manager.running_ids()), + "max_concurrent_downloads": app_settings.max_concurrent_downloads, + }, + } diff --git a/backend/config.py b/backend/config.py index 0d66e32..02cb31c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -32,32 +32,48 @@ class Settings(BaseSettings): ) host: str = "127.0.0.1" - port: int = 8787 + port: int = Field(default=8787, ge=1, le=65535) library_path: Path = ROOT_DIR / "library" + data_path: Path = ROOT_DIR / "data" database_url: str = f"sqlite+aiosqlite:///{(ROOT_DIR / 'data' / 'animebox.db').as_posix()}" log_level: str = "INFO" http_proxy: str | None = None + # Per-stage provider budgets. A slow provider is reported, never allowed to block others. provider_search_timeout: float = Field(default=10.0, gt=0) - preferred_voiceovers: str = "Aniliberty,Animevost,AniLibria,AnimeVost" + provider_detail_timeout: float = Field(default=25.0, gt=0) + provider_stream_timeout: float = Field(default=25.0, gt=0) + preferred_voiceovers: str = "AniLibria,AnimeVost,Aniliberty,Animevost" default_quality: int = 1080 + max_concurrent_downloads: int = Field(default=2, ge=1, le=6) steam_deck_crf: int = 23 steam_deck_height: int = 720 + media_session_ttl: int = Field(default=4 * 3600, ge=60) cors_origins: list[str] = ["http://127.0.0.1:5173", "http://localhost:5173"] + # Extra Host header values accepted besides loopback names (e.g. a LAN hostname). + allowed_hosts: list[str] = [] def model_post_init(self, _context: object) -> None: self.library_path = _rooted_path(self.library_path) + self.data_path = _rooted_path(self.data_path) self.database_url = _rooted_database_url(self.database_url) @property def preferred_voiceover_list(self) -> list[str]: return [v.strip() for v in self.preferred_voiceovers.split(",") if v.strip()] + @property + def database_file(self) -> Path | None: + url = make_url(self.database_url) + if not url.drivername.startswith("sqlite") or not url.database or url.database == ":memory:": + return None + return Path(url.database) + @lru_cache def get_settings() -> Settings: settings = Settings() settings.library_path.mkdir(parents=True, exist_ok=True) - database_url = make_url(settings.database_url) - if database_url.drivername.startswith("sqlite") and database_url.database and database_url.database != ":memory:": - Path(database_url.database).parent.mkdir(parents=True, exist_ok=True) + settings.data_path.mkdir(parents=True, exist_ok=True) + if settings.database_file is not None: + settings.database_file.parent.mkdir(parents=True, exist_ok=True) return settings diff --git a/backend/database/migrations.py b/backend/database/migrations.py new file mode 100644 index 0000000..a5d0a9d --- /dev/null +++ b/backend/database/migrations.py @@ -0,0 +1,291 @@ +"""Versioned, repeatable SQLite migrations. + +The schema version lives in ``PRAGMA user_version``. AnimeBox 1.x databases +were created by ``create_all`` and report version 0. + +Rules: + +- an existing database is copied to ``/backups/`` before any step runs; +- all steps of one upgrade run in a single transaction (all-or-nothing); +- every step is idempotent: it inspects the current schema/data before acting, + so re-running it after an interrupted upgrade is safe; +- user data is never discarded. Duplicate rows produced by the 1.x id-encoding + bug are merged, keeping the most useful record. +""" + +from __future__ import annotations + +import logging +import sqlite3 +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from backend.config import ROOT_DIR +from backend.providers.ids import safe_canonical_id + +logger = logging.getLogger("animebox.migrations") + +LATEST_VERSION = 1 +MAX_BACKUPS = 5 +CORE_TABLES = ("anime_titles", "library_episodes", "download_jobs", "watch_history", "app_settings") + + +@dataclass +class MigrationContext: + library_root: Path + app_root: Path = ROOT_DIR + + +def _table_exists(conn: sqlite3.Connection, table: str) -> bool: + return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() is not None + + +def _columns(conn: sqlite3.Connection, table: str) -> set[str]: + return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def _add_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None: + if _table_exists(conn, table) and column not in _columns(conn, table): + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + + +def _normalize_path(stored: str, ctx: MigrationContext) -> str: + """Convert 1.x cwd-relative/absolute paths to library-relative POSIX paths where possible.""" + if not stored: + return stored + root = ctx.library_root.resolve() + raw = stored.replace("\\", "/") + candidate = Path(raw) + looks_absolute = candidate.is_absolute() or (len(raw) > 2 and raw[1] == ":" and raw[2] == "/") + if looks_absolute: + try: + return Path(raw).resolve().relative_to(root).as_posix() + except (ValueError, OSError): + return stored + for base in (ctx.app_root, root): + try: + return (base / candidate).resolve().relative_to(root).as_posix() + except (ValueError, OSError): + continue + return raw + + +def _resolve(stored: str, ctx: MigrationContext) -> Path: + path = Path(stored) + return path if path.is_absolute() else ctx.library_root / path + + +def _file_ok(stored: str, ctx: MigrationContext) -> bool: + path = _resolve(stored, ctx) + try: + return path.is_file() and path.stat().st_size > 0 and ".part" not in path.name.lower() + except OSError: + return False + + +def _canonicalize_ids(conn: sqlite3.Connection, table: str, column: str = "anime_external_id") -> None: + if not _table_exists(conn, table): + return + rows = conn.execute(f"SELECT id, {column} FROM {table}").fetchall() + for row_id, value in rows: + canonical = safe_canonical_id(value) + if canonical != value: + conn.execute(f"UPDATE {table} SET {column}=? WHERE id=?", (canonical, row_id)) + + +def _create_missing_tables(conn: sqlite3.Connection) -> None: + """Partial 1.x databases: create absent tables (with the current schema) so later steps can run.""" + from sqlalchemy.dialects import sqlite + from sqlalchemy.schema import CreateIndex, CreateTable + + from backend import models # noqa: F401 + from backend.database.session import Base + + for table in Base.metadata.sorted_tables: + if _table_exists(conn, table.name): + continue + conn.execute(str(CreateTable(table).compile(dialect=sqlite.dialect()))) + for index in table.indexes: + conn.execute(str(CreateIndex(index, if_not_exists=True).compile(dialect=sqlite.dialect()))) + + +def _migrate_to_v1(conn: sqlite3.Connection, ctx: MigrationContext) -> None: + _create_missing_tables(conn) + + # 1. New columns. + _add_column(conn, "library_episodes", "quality", "INTEGER") + _add_column(conn, "download_jobs", "warning", "TEXT") + _add_column(conn, "download_jobs", "quality", "INTEGER") + _add_column(conn, "download_jobs", "player_host", "VARCHAR(256)") + _add_column(conn, "download_jobs", "bytes_done", "INTEGER NOT NULL DEFAULT 0") + _add_column(conn, "download_jobs", "bytes_total", "INTEGER") + _add_column(conn, "download_jobs", "speed_bps", "FLOAT") + _add_column(conn, "download_jobs", "attempts", "INTEGER NOT NULL DEFAULT 0") + _add_column(conn, "download_jobs", "resumable", "BOOLEAN NOT NULL DEFAULT 0") + _add_column(conn, "download_jobs", "finished_at", "DATETIME") + _add_column(conn, "watch_history", "voiceover", "VARCHAR(256)") + + # 2. Canonical ids everywhere. + _canonicalize_ids(conn, "library_episodes") + _canonicalize_ids(conn, "download_jobs") + _canonicalize_ids(conn, "watch_history") + + # anime_titles.external_id is UNIQUE: merge, keeping the most recently updated row. + titles = conn.execute( + "SELECT id, external_id, folder_path FROM anime_titles ORDER BY updated_at DESC, id DESC" + ).fetchall() + kept: dict[str, int] = {} + for row_id, external_id, _folder in titles: + canonical = safe_canonical_id(external_id) + if canonical in kept: + conn.execute("DELETE FROM anime_titles WHERE id=?", (row_id,)) + continue + kept[canonical] = row_id + for canonical, row_id in kept.items(): + conn.execute("UPDATE anime_titles SET external_id=? WHERE id=?", (canonical, row_id)) + + # 3. Library-relative file paths. + for row_id, stored in conn.execute("SELECT id, file_path FROM library_episodes").fetchall(): + normalized = _normalize_path(stored, ctx) + if normalized != stored: + conn.execute("UPDATE library_episodes SET file_path=? WHERE id=?", (normalized, row_id)) + + for row_id, stored in conn.execute("SELECT id, folder_path FROM anime_titles WHERE folder_path IS NOT NULL").fetchall(): + normalized = _normalize_path(stored, ctx) + if normalized != stored: + conn.execute("UPDATE anime_titles SET folder_path=? WHERE id=?", (normalized, row_id)) + + # 4. One library row per episode: prefer a row whose file exists, then the newest download. + groups: dict[tuple, list[tuple]] = {} + for row in conn.execute( + "SELECT id, anime_external_id, season, episode, file_path, downloaded_at, progress_seconds " + "FROM library_episodes" + ).fetchall(): + groups.setdefault((row[1], row[2], row[3]), []).append(row) + for rows in groups.values(): + if len(rows) < 2: + continue + rows.sort(key=lambda r: (_file_ok(r[4], ctx), r[5] or "", r[0]), reverse=True) + keep = rows[0] + best_progress = max(r[6] or 0 for r in rows) + conn.execute("UPDATE library_episodes SET progress_seconds=? WHERE id=?", (best_progress, keep[0])) + for duplicate in rows[1:]: + conn.execute("UPDATE watch_history SET local_episode_id=? WHERE local_episode_id=?", (keep[0], duplicate[0])) + conn.execute("DELETE FROM library_episodes WHERE id=?", (duplicate[0],)) + + # 5. One history row per episode: keep the latest progress. + groups = {} + for row in conn.execute( + "SELECT id, anime_external_id, season, episode, updated_at, local_episode_id FROM watch_history" + ).fetchall(): + groups.setdefault((row[1], row[2], row[3]), []).append(row) + for rows in groups.values(): + if len(rows) < 2: + continue + rows.sort(key=lambda r: (r[4] or "", r[0]), reverse=True) + keep = rows[0] + local_id = next((r[5] for r in rows if r[5]), None) + if local_id and not keep[5]: + conn.execute("UPDATE watch_history SET local_episode_id=? WHERE id=?", (local_id, keep[0])) + for duplicate in rows[1:]: + conn.execute("DELETE FROM watch_history WHERE id=?", (duplicate[0],)) + + # 6. Browsing no longer implies library membership (root cause of the empty-library bug). + conn.execute( + "UPDATE anime_titles SET folder_path=NULL WHERE external_id NOT IN " + "(SELECT DISTINCT anime_external_id FROM library_episodes)" + ) + + # 7. Constraints and indexes (names match backend/models). + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_library_episodes_key " + "ON library_episodes (anime_external_id, season, episode)" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_watch_history_key ON watch_history (anime_external_id, season, episode)" + ) + conn.execute("CREATE INDEX IF NOT EXISTS ix_watch_history_updated_at ON watch_history (updated_at)") + conn.execute( + "CREATE INDEX IF NOT EXISTS ix_download_jobs_episode_key ON download_jobs (anime_external_id, season, episode)" + ) + + +MIGRATIONS: dict[int, Callable[[sqlite3.Connection, MigrationContext], None]] = { + 1: _migrate_to_v1, +} + + +def backup_database(db_path: Path, version: int) -> Path: + backup_dir = db_path.parent / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + target = backup_dir / f"{db_path.stem}-v{version}-{stamp}.db" + source = sqlite3.connect(db_path) + try: + dest = sqlite3.connect(target) + try: + source.backup(dest) + finally: + dest.close() + finally: + source.close() + backups = sorted(backup_dir.glob(f"{db_path.stem}-v*.db"), key=lambda p: p.stat().st_mtime) + for stale in backups[:-MAX_BACKUPS]: + stale.unlink(missing_ok=True) + return target + + +def current_version(conn: sqlite3.Connection) -> int: + return int(conn.execute("PRAGMA user_version").fetchone()[0]) + + +def has_schema(conn: sqlite3.Connection) -> bool: + return any(_table_exists(conn, table) for table in CORE_TABLES) + + +def upgrade(db_path: Path, ctx: MigrationContext) -> tuple[int, int, Path | None]: + """Upgrade an on-disk database. Returns ``(from_version, to_version, backup_path)``. + + A database without AnimeBox tables is left alone; ``init_db`` creates the + schema and stamps it with :data:`LATEST_VERSION`. + """ + if not db_path.exists(): + return LATEST_VERSION, LATEST_VERSION, None + conn = sqlite3.connect(db_path, isolation_level=None) + try: + version = current_version(conn) + if not has_schema(conn) or version >= LATEST_VERSION: + return version, version, None + finally: + conn.close() + + backup = backup_database(db_path, version) + logger.info("Backed up database to %s before upgrading schema v%s -> v%s", backup, version, LATEST_VERSION) + + conn = sqlite3.connect(db_path, isolation_level=None) + try: + conn.execute("BEGIN IMMEDIATE") + try: + for target in range(version + 1, LATEST_VERSION + 1): + logger.info("Applying migration v%s", target) + MIGRATIONS[target](conn, ctx) + conn.execute(f"PRAGMA user_version = {LATEST_VERSION}") + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + logger.exception("Database migration failed; database left unchanged. Backup: %s", backup) + raise + finally: + conn.close() + return version, LATEST_VERSION, backup + + +def stamp_latest(db_path: Path) -> None: + conn = sqlite3.connect(db_path, isolation_level=None) + try: + if current_version(conn) < LATEST_VERSION: + conn.execute(f"PRAGMA user_version = {LATEST_VERSION}") + finally: + conn.close() diff --git a/backend/database/session.py b/backend/database/session.py index aaa6c8b..5345ae6 100644 --- a/backend/database/session.py +++ b/backend/database/session.py @@ -1,10 +1,17 @@ +from __future__ import annotations + +import asyncio +import logging from collections.abc import AsyncGenerator +from sqlalchemy import event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from backend.config import get_settings +logger = logging.getLogger("animebox.database") + class Base(DeclarativeBase): pass @@ -15,6 +22,19 @@ class Base(DeclarativeBase): SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) +@event.listens_for(engine.sync_engine, "connect") +def _sqlite_pragmas(dbapi_connection, _record) -> None: # pragma: no cover - driver hook + if not settings.database_url.startswith("sqlite"): + return + cursor = dbapi_connection.cursor() + # WAL lets the UI read while downloads write progress; busy_timeout avoids "database is locked". + if settings.database_file is not None: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + async def get_db() -> AsyncGenerator[AsyncSession, None]: async with SessionLocal() as session: yield session @@ -22,6 +42,17 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def init_db() -> None: from backend import models # noqa: F401 + from backend.database import migrations + + db_file = settings.database_file + if db_file is not None: + ctx = migrations.MigrationContext(library_root=settings.library_path) + before, after, backup = await asyncio.to_thread(migrations.upgrade, db_file, ctx) + if backup is not None: + logger.info("Database upgraded v%s -> v%s (backup: %s)", before, after, backup) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + + if db_file is not None: + await asyncio.to_thread(migrations.stamp_latest, db_file) diff --git a/backend/downloader/manager.py b/backend/downloader/manager.py index 62efd95..1294dbf 100644 --- a/backend/downloader/manager.py +++ b/backend/downloader/manager.py @@ -1,60 +1,149 @@ -"""Download manager — queue + progress (Hakuneko DownloadManager patterns).""" +"""Download manager 2.0 — durable queue with real progress. + +Ideas from HakuNeko's DownloadManager and Sonarr's queue/import lifecycle: + +- the queue lives in SQLite; jobs survive restarts and resume as ``queued``; +- up to ``max_concurrent_downloads`` jobs run at once; +- HLS goes through ffmpeg via the local media proxy (same network identity as + playback); progress comes from ffmpeg's reported position/duration; +- progressive MP4/WebM is fetched directly with HTTP Range support, which is + the only case where pause/resume is offered; +- files are written to ``*.part.*`` and moved into place atomically after + validation; the library row is written only after the file is final; +- poster/metadata are best effort and never fail a valid download. +""" from __future__ import annotations import asyncio import logging +import os +import time +from datetime import datetime, timezone from pathlib import Path -import httpx from sqlalchemy import select +from backend.config import get_settings from backend.database.session import SessionLocal -from backend.models import DownloadJob, DownloadStatus, LibraryEpisode -from backend.providers import get_registry -from backend.services import ffmpeg_tools, library -from backend.services.settings import get_app_settings +from backend.models import ACTIVE_DOWNLOAD_STATUSES, OPEN_DOWNLOAD_STATUSES, DownloadJob, DownloadStatus +from backend.player.sessions import UnsafeUpstream, media_sessions +from backend.providers.errors import NotFound, ProviderError +from backend.providers.http import DEFAULT_USER_AGENT, media_clients +from backend.services import ffmpeg_tools, library, library_index logger = logging.getLogger("animebox.downloader") +MAX_ATTEMPTS = 3 +RETRY_DELAYS = (3.0, 10.0) +PROGRESS_WRITE_INTERVAL = 1.0 + + +class _Stop(Exception): + """Raised inside a job when it must stop (pause/cancel/shutdown).""" + + +class DownloadValidationError(RuntimeError): + pass + + +class _Progress: + """Throttled progress reporting with an exponentially smoothed speed.""" + + def __init__(self, manager: "DownloadManager", job_id: int, start: float, span: float) -> None: + self.manager = manager + self.job_id = job_id + self.start = start + self.span = span + self._last_write = 0.0 + self._last_bytes = 0 + self._last_time = time.monotonic() + self.speed: float | None = None + + def update(self, fraction: float | None, bytes_done: int, bytes_total: int | None = None) -> None: + now = time.monotonic() + elapsed = now - self._last_time + if elapsed >= 0.5 and bytes_done >= self._last_bytes: + instant = (bytes_done - self._last_bytes) / elapsed + self.speed = instant if self.speed is None else 0.7 * self.speed + 0.3 * instant + self._last_bytes, self._last_time = bytes_done, now + if now - self._last_write < PROGRESS_WRITE_INTERVAL: + return + self._last_write = now + fields: dict = {"bytes_done": bytes_done, "speed_bps": self.speed} + if bytes_total: + fields["bytes_total"] = bytes_total + if fraction is not None: + fields["progress"] = round(self.start + self.span * max(0.0, min(1.0, fraction)), 1) + self.manager._schedule_update(self.job_id, fields) + class DownloadManager: def __init__(self) -> None: - self._queue: asyncio.Queue[int] = asyncio.Queue() - self._worker: asyncio.Task | None = None - self._cancel_flags: set[int] = set() - self._lock = asyncio.Lock() + self._concurrency = get_settings().max_concurrent_downloads + self._running: dict[int, asyncio.Task] = {} + self._stop_reason: dict[int, str] = {} + self._wakeup = asyncio.Event() + self._scheduler: asyncio.Task | None = None + self._pending_writes: set[asyncio.Task] = set() + self.local_base_url = f"http://127.0.0.1:{get_settings().port}" + # ------------------------------------------------------------- lifecycle async def start(self) -> None: - if self._worker is None or self._worker.done(): - self._worker = asyncio.create_task(self._run(), name="download-worker") - await self._requeue_pending() + if self._scheduler is not None and not self._scheduler.done(): + return + await self._recover() + self._wakeup.set() # start queued work immediately + self._scheduler = asyncio.create_task(self._schedule_loop(), name="download-scheduler") async def stop(self) -> None: - if self._worker: - self._worker.cancel() + if self._scheduler is not None: + self._scheduler.cancel() try: - await self._worker + await self._scheduler except asyncio.CancelledError: pass - finally: - self._worker = None + self._scheduler = None + running = list(self._running.items()) + for job_id, task in running: + self._stop_reason[job_id] = "shutdown" + task.cancel() + for _job_id, task in running: + try: + await task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + if self._pending_writes: + await asyncio.gather(*self._pending_writes, return_exceptions=True) - async def _requeue_pending(self) -> None: + def set_concurrency(self, value: int) -> None: + self._concurrency = max(1, min(6, int(value))) + self._wakeup.set() + + async def _recover(self) -> None: + """Interrupted jobs go back to the queue; orphaned partial files are removed.""" + keep_parts: set[Path] = set() async with SessionLocal() as db: - rows = ( - await db.execute( - select(DownloadJob).where( - DownloadJob.status.in_([DownloadStatus.queued.value, DownloadStatus.downloading.value]) - ) - ) - ).scalars().all() + rows = (await db.execute(select(DownloadJob).where(DownloadJob.status.in_(OPEN_DOWNLOAD_STATUSES)))).scalars().all() for job in rows: - job.status = DownloadStatus.queued.value - job.progress = 0 - await self._queue.put(job.id) + if job.status in ACTIVE_DOWNLOAD_STATUSES: + job.status = DownloadStatus.queued.value + job.speed_bps = None + if job.resumable and job.file_path: + keep_parts.add(library.partial_path(library.resolve_stored_path(job.file_path)).resolve()) + else: + job.progress = 0 + job.bytes_done = 0 await db.commit() + root = library.library_root() + if root.is_dir(): + for part in root.rglob(f"*{library.PART_MARKER}.*"): + if part.is_file() and part.resolve() not in keep_parts: + logger.info("Removing incomplete temporary file %s", part.name) + part.unlink(missing_ok=True) + library.remove_empty_dirs(root) + # -------------------------------------------------------------- commands async def enqueue( self, *, @@ -66,8 +155,25 @@ async def enqueue( episode_title: str | None, voiceover: str | None, steam_deck: bool, - ) -> DownloadJob: + quality: int | None = None, + replace: bool = False, + ) -> tuple[DownloadJob | None, str | None]: + """Queue one episode. Returns ``(job, None)`` or ``(None, skip_reason)``.""" async with SessionLocal() as db: + open_job = ( + await db.execute( + select(DownloadJob.id).where( + DownloadJob.anime_external_id == anime_id, + DownloadJob.season == season, + DownloadJob.episode == episode, + DownloadJob.status.in_(OPEN_DOWNLOAD_STATUSES), + ) + ) + ).first() + if open_job: + return None, "already_queued" + if not replace and (season, episode) in await library_index.downloaded_keys(db, anime_id): + return None, "already_downloaded" job = DownloadJob( anime_external_id=anime_id, anime_title=anime_title, @@ -79,219 +185,491 @@ async def enqueue( status=DownloadStatus.queued.value, progress=0, steam_deck=steam_deck, + quality=quality, ) db.add(job) await db.commit() await db.refresh(job) - job_id = job.id - await self._queue.put(job_id) - return job + self._wakeup.set() + return job, None async def cancel(self, job_id: int) -> bool: - self._cancel_flags.add(job_id) async with SessionLocal() as db: job = await db.get(DownloadJob, job_id) - if not job: + if job is None or job.status not in (*OPEN_DOWNLOAD_STATUSES, DownloadStatus.failed.value): return False - if job.status in (DownloadStatus.queued.value, DownloadStatus.downloading.value, DownloadStatus.failed.value): + if job_id not in self._running: job.status = DownloadStatus.cancelled.value + job.speed_bps = None + job.finished_at = datetime.now(timezone.utc) + self._remove_part(job.file_path) await db.commit() return True - return False + self._stop_reason[job_id] = "cancel" + self._running[job_id].cancel() + return True + + async def pause(self, job_id: int) -> bool: + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + if job is None or not job.resumable or job.status not in ACTIVE_DOWNLOAD_STATUSES: + return False + if job_id not in self._running: + job.status = DownloadStatus.paused.value + await db.commit() + return True + self._stop_reason[job_id] = "pause" + self._running[job_id].cancel() + return True + + async def resume(self, job_id: int) -> bool: + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + if job is None or job.status != DownloadStatus.paused.value: + return False + job.status = DownloadStatus.queued.value + await db.commit() + self._wakeup.set() + return True async def retry(self, job_id: int) -> bool: async with SessionLocal() as db: job = await db.get(DownloadJob, job_id) - if not job: + if job is None or job.status not in (DownloadStatus.failed.value, DownloadStatus.cancelled.value): + return False + busy = ( + await db.execute( + select(DownloadJob.id).where( + DownloadJob.id != job_id, + DownloadJob.anime_external_id == job.anime_external_id, + DownloadJob.season == job.season, + DownloadJob.episode == job.episode, + DownloadJob.status.in_(OPEN_DOWNLOAD_STATUSES), + ) + ) + ).first() + if busy: return False job.status = DownloadStatus.queued.value job.progress = 0 + job.bytes_done = 0 + job.bytes_total = None + job.speed_bps = None job.error = None + job.warning = None + job.attempts = 0 + job.resumable = False + job.finished_at = None await db.commit() - self._cancel_flags.discard(job_id) - await self._queue.put(job_id) + self._wakeup.set() return True - async def _run(self) -> None: + async def remove(self, job_id: int) -> bool: + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + if job is None or job.status in OPEN_DOWNLOAD_STATUSES: + return False + await db.delete(job) + await db.commit() + return True + + async def clear_finished(self) -> int: + async with SessionLocal() as db: + rows = ( + await db.execute( + select(DownloadJob).where( + DownloadJob.status.in_( + [DownloadStatus.completed.value, DownloadStatus.cancelled.value, DownloadStatus.failed.value] + ) + ) + ) + ).scalars().all() + for row in rows: + await db.delete(row) + await db.commit() + return len(rows) + + # ------------------------------------------------------------- scheduler + async def _schedule_loop(self) -> None: while True: - job_id = await self._queue.get() try: - async with self._lock: - await self._process(job_id) - except Exception as exc: - logger.exception("Download job %s crashed: %s", job_id, exc) - finally: - self._queue.task_done() + await asyncio.wait_for(self._wakeup.wait(), timeout=5) + except TimeoutError: + pass + self._wakeup.clear() + try: + await self._fill_slots() + except Exception: # noqa: BLE001 - scheduler must survive DB hiccups + logger.exception("Download scheduler error") + async def _fill_slots(self) -> None: + free = self._concurrency - len(self._running) + if free <= 0: + return + async with SessionLocal() as db: + ids = ( + await db.execute( + select(DownloadJob.id) + .where(DownloadJob.status == DownloadStatus.queued.value) + .order_by(DownloadJob.id) + .limit(free + len(self._running)) + ) + ).scalars().all() + for job_id in ids: + if len(self._running) >= self._concurrency: + break + if job_id in self._running: + continue + self._running[job_id] = asyncio.create_task(self._run_job(job_id), name=f"download-{job_id}") + + async def _run_job(self, job_id: int) -> None: + try: + await self._process(job_id) + except asyncio.CancelledError: + reason = self._stop_reason.get(job_id, "shutdown") + await self._on_stopped(job_id, reason) + except Exception as exc: # noqa: BLE001 + logger.exception("Download job %s crashed", job_id) + await self._update(job_id, status=DownloadStatus.failed.value, error=str(exc)[:2000], speed_bps=None) + finally: + self._running.pop(job_id, None) + self._stop_reason.pop(job_id, None) + self._wakeup.set() + + async def _on_stopped(self, job_id: int, reason: str) -> None: + await self._flush_progress() + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + if job is None: + return + job.speed_bps = None + if reason == "pause" and job.resumable: + job.status = DownloadStatus.paused.value + elif reason == "shutdown": + job.status = DownloadStatus.queued.value # resumes on next start + if not job.resumable: + self._remove_part(job.file_path) + job.progress = 0 + job.bytes_done = 0 + else: + job.status = DownloadStatus.cancelled.value + job.finished_at = datetime.now(timezone.utc) + self._remove_part(job.file_path) + await db.commit() + + @staticmethod + def _remove_part(stored_final: str | None) -> None: + if not stored_final: + return + final = library.resolve_stored_path(stored_final) + for candidate in (library.partial_path(final), library.partial_path(final.with_suffix(".mkv"))): + candidate.unlink(missing_ok=True) + # Do not leave an empty title folder behind for a download that never completed. + anime_dir = final.parent.parent + if anime_dir.is_dir() and anime_dir.resolve() != library.library_root().resolve(): + library.remove_empty_dirs(anime_dir, keep_root=False) + + # ------------------------------------------------------------ DB helpers async def _update(self, job_id: int, **fields) -> None: async with SessionLocal() as db: job = await db.get(DownloadJob, job_id) - if not job: + if job is None: return - for k, v in fields.items(): - setattr(job, k, v) + for key, value in fields.items(): + setattr(job, key, value) await db.commit() + async def _update_progress(self, job_id: int, fields: dict) -> None: + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + # A late progress write must never touch a finished/stopped job. + if job is None or job.status not in (DownloadStatus.downloading.value, DownloadStatus.processing.value): + return + for key, value in fields.items(): + setattr(job, key, value) + await db.commit() + + def _schedule_update(self, job_id: int, fields: dict) -> None: + task = asyncio.create_task(self._update_progress(job_id, fields)) + self._pending_writes.add(task) + task.add_done_callback(self._pending_writes.discard) + + async def _flush_progress(self) -> None: + if self._pending_writes: + await asyncio.gather(*list(self._pending_writes), return_exceptions=True) + + # ---------------------------------------------------------------- worker async def _process(self, job_id: int) -> None: - if job_id in self._cancel_flags: - self._cancel_flags.discard(job_id) - await self._update(job_id, status=DownloadStatus.cancelled.value) - return + from backend.providers import get_registry + from backend.services.settings import get_app_settings async with SessionLocal() as db: job = await db.get(DownloadJob, job_id) - if not job or job.status == DownloadStatus.cancelled.value: + if job is None or job.status != DownloadStatus.queued.value: return - anime_id = job.anime_external_id - anime_title = job.anime_title - season = job.season - episode = job.episode - voiceover = job.voiceover - steam_deck = job.steam_deck - source = job.source - episode_title = job.episode_title settings = await get_app_settings(db) - if settings.steam_deck_optimize: - steam_deck = True + snapshot = { + "anime_id": job.anime_external_id, + "title": job.anime_title, + "source": job.source, + "season": job.season, + "episode": job.episode, + "episode_title": job.episode_title, + "voiceover": job.voiceover, + "quality": job.quality or settings.default_quality, + "steam_deck": job.steam_deck or settings.steam_deck_optimize, + "resume_from": job.file_path if job.resumable else None, + "resume_url": job.source_url if job.resumable else None, + "bytes_total": job.bytes_total, + } - await self._update(job_id, status=DownloadStatus.downloading.value, progress=1, error=None) + registry = get_registry() + last_error: Exception | None = None + for attempt in range(1, MAX_ATTEMPTS + 1): + await self._update( + job_id, status=DownloadStatus.resolving.value, attempts=attempt, error=None, speed_bps=None + ) + try: + final = await self._attempt(job_id, registry, snapshot, refresh=attempt > 1) + except (NotFound, DownloadValidationError) as exc: + last_error = exc + if isinstance(exc, NotFound): + break # the episode/voiceover is gone; retrying will not help + except (ProviderError, RuntimeError, OSError, UnsafeUpstream, LookupError) as exc: + last_error = exc + except Exception as exc: # noqa: BLE001 - httpx and extractor errors + last_error = exc + else: + logger.info("Downloaded %s S%sE%s -> %s", snapshot["title"], snapshot["season"], snapshot["episode"], final) + return + snapshot["resume_from"] = snapshot["resume_url"] = None + logger.warning("Download job %s attempt %s failed: %s", job_id, attempt, last_error) + if attempt < MAX_ATTEMPTS: + await self._update(job_id, warning=f"Attempt {attempt} failed: {str(last_error)[:300]}; retrying") + await asyncio.sleep(RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)]) + message = getattr(last_error, "message", None) or str(last_error) or type(last_error).__name__ + await self._flush_progress() + async with SessionLocal() as db: + job = await db.get(DownloadJob, job_id) + if job is not None: + self._remove_part(job.file_path) + job.resumable = False + await db.commit() + await self._update( + job_id, + status=DownloadStatus.failed.value, + error=message[:2000], + speed_bps=None, + finished_at=datetime.now(timezone.utc), + ) - try: - registry = get_registry() - stream = await registry.resolve_stream(anime_id, episode, voiceover=voiceover) - await self._update(job_id, voiceover=stream.voiceover, source_url=stream.video.url, progress=5) + async def _attempt(self, job_id: int, registry, snap: dict, *, refresh: bool) -> Path: + stream = await registry.resolve_stream( + snap["anime_id"], snap["episode"], snap["voiceover"], snap["quality"], purpose="download", refresh=refresh + ) + ext = "webm" if stream.video.type == "webm" else "mp4" + final = library.episode_file_path(snap["title"], snap["season"], snap["episode"], ext) + part = library.partial_path(final) + resumable = stream.video.type in ("mp4", "webm") + await self._update( + job_id, + status=DownloadStatus.downloading.value, + voiceover=stream.voiceover, + player_host=stream.player, + quality=stream.video.quality, + source_url=stream.video.url if resumable else None, + file_path=library.to_stored_path(final), + resumable=resumable, + warning=None, + ) + library.ensure_dir(final.parent) + steam_deck = bool(snap["steam_deck"]) and ffmpeg_tools.ffmpeg_available() + span = 80.0 if steam_deck else 100.0 + progress = _Progress(self, job_id, 0.0, span) - if job_id in self._cancel_flags: - self._cancel_flags.discard(job_id) - await self._update(job_id, status=DownloadStatus.cancelled.value) - return + expected_duration: list[float | None] = [None] + if stream.video.type == "m3u8": + session = media_sessions.create(stream.video.headers, registry.proxy, ttl=24 * 3600) + try: + url = self.local_base_url + session.url_for(stream.video.url) - # Browser-safe containers make downloaded episodes playable without transcoding. - ext = "webm" if stream.video.type == "webm" else "mp4" - dest = library.episode_path(anime_title, season, episode, ext=ext) + def on_progress(p: ffmpeg_tools.FfmpegProgress) -> None: + expected_duration[0] = p.duration or expected_duration[0] + progress.update(p.fraction, p.total_size) + + part.unlink(missing_ok=True) + await ffmpeg_tools.download_stream_with_ffmpeg(url, part, on_progress=on_progress, hls=True) + finally: + media_sessions.drop(session.id) + elif stream.video.type == "mpd": + def on_progress_mpd(p: ffmpeg_tools.FfmpegProgress) -> None: + expected_duration[0] = p.duration or expected_duration[0] + progress.update(p.fraction, p.total_size) + + part.unlink(missing_ok=True) + await ffmpeg_tools.download_stream_with_ffmpeg( + stream.video.url, part, stream.video.headers, on_progress=on_progress_mpd + ) + else: + resume_url = snap.get("resume_url") + if not resume_url: + part.unlink(missing_ok=True) + await self._download_progressive(job_id, resume_url or stream.video.url, stream.video.headers, part, progress) - if stream.video.type in ("m3u8", "mpd"): - tmp = await self._download_hls( - job_id, - stream.video.url, - dest, - stream.video.headers, + await self._validate(part, expected_duration[0], snap.get("bytes_total") if resumable else None) + + warning: str | None = None + produced = part + if steam_deck: + await self._update(job_id, status=DownloadStatus.processing.value, progress=80.0, speed_bps=None) + optimized_final = final.with_suffix(".mkv") + optimized_part = library.partial_path(optimized_final) + try: + optimize_progress = _Progress(self, job_id, 80.0, 20.0) + await ffmpeg_tools.optimize_for_steam_deck( + part, optimized_part, on_progress=lambda p: optimize_progress.update(p.fraction, p.total_size) ) + await self._validate(optimized_part, expected_duration[0], None) + except asyncio.CancelledError: + optimized_part.unlink(missing_ok=True) + raise + except Exception as exc: # noqa: BLE001 - keep the valid original + optimized_part.unlink(missing_ok=True) + warning = f"Steam Deck optimization failed, original kept: {str(exc)[:300]}" + logger.warning("Job %s: %s", job_id, warning) else: - tmp = dest.with_name(f"{dest.stem}.part{dest.suffix}") - await self._download_progressive(job_id, stream.video.url, tmp, stream.video.headers) + part.unlink(missing_ok=True) + produced, final = optimized_part, optimized_final - if job_id in self._cancel_flags: - self._cancel_flags.discard(job_id) - tmp.unlink(missing_ok=True) - await self._update(job_id, status=DownloadStatus.cancelled.value) - return + os.replace(produced, final) # atomic within the season directory + await self._finalize(job_id, snap, stream, final, warning) + return final + + async def _download_progressive(self, job_id: int, url: str, headers: dict[str, str], part: Path, progress: _Progress) -> None: + from backend.player.proxy import _open_upstream + from backend.providers import get_registry - final_path = dest - if steam_deck and ffmpeg_tools.ffmpeg_available(): - await self._update(job_id, progress=90) - optimized = dest.with_name(dest.stem + ".steamdeck.mkv") - await ffmpeg_tools.optimize_for_steam_deck(tmp, optimized) - tmp.unlink(missing_ok=True) - if dest.exists() and dest != optimized: - dest.unlink(missing_ok=True) - final_path = optimized + offset = part.stat().st_size if part.exists() else 0 + session = media_sessions.create(headers or {"User-Agent": DEFAULT_USER_AGENT}, get_registry().proxy, ttl=3600) + try: + response, _final_url = await _open_upstream(session, url, f"bytes={offset}-" if offset else None) + finally: + media_sessions.drop(session.id) + try: + if response.status_code >= 400: + raise RuntimeError(f"Media host answered HTTP {response.status_code}") + total: int | None = None + if offset and response.status_code == 206: + content_range = response.headers.get("content-range", "") + total = int(content_range.rsplit("/", 1)[-1]) if content_range.rsplit("/", 1)[-1].isdigit() else None + mode = "ab" else: - tmp.replace(dest) + offset = 0 + mode = "wb" + length = response.headers.get("content-length") + total = int(length) if length and length.isdigit() else None + if total: + await self._update(job_id, bytes_total=total) + done = offset + with part.open(mode) as handle: + async for chunk in response.aiter_bytes(512 * 1024): + handle.write(chunk) + done += len(chunk) + progress.update(done / total if total else None, done, total) + if total and done != total: + raise DownloadValidationError(f"Incomplete download: {done} of {total} bytes") + finally: + await response.aclose() - library.append_episode_metadata( - anime_title, - season=season, - episode=episode, + async def _validate(self, path: Path, expected_duration: float | None, expected_size: int | None) -> None: + size = library.file_size(path) or 0 + if size < library.MIN_MEDIA_BYTES: + path.unlink(missing_ok=True) + raise DownloadValidationError("Downloaded file is empty or truncated") + if expected_size and size != expected_size: + raise DownloadValidationError(f"Downloaded file size mismatch: {size} of {expected_size} bytes") + if not ffmpeg_tools.ffmpeg_available(): + return + duration = await ffmpeg_tools.probe_duration(path) + if duration is None or duration <= 0: + path.unlink(missing_ok=True) + raise DownloadValidationError("Downloaded file is not a readable video") + if expected_duration and duration < expected_duration * 0.95: + path.unlink(missing_ok=True) + raise DownloadValidationError( + f"Download incomplete: {duration:.0f}s of {expected_duration:.0f}s" + ) + + async def _finalize(self, job_id: int, snap: dict, stream, final: Path, warning: str | None) -> None: + await self._flush_progress() + anime_dir = final.parent.parent + async with SessionLocal() as db: + row, previous = await library_index.record_episode( + db, + anime_id=snap["anime_id"], + season=snap["season"], + episode=snap["episode"], + episode_title=snap["episode_title"], voiceover=stream.voiceover, - file_name=final_path.name, - source=source, + path=final, + quality=stream.video.quality, + ) + await library_index.upsert_title( + db, anime_id=snap["anime_id"], source=snap["source"], title=snap["title"], folder=anime_dir ) - detail = await registry.get_anime(anime_id) - await library.save_poster(anime_title, detail.poster) + job = await db.get(DownloadJob, job_id) + if job is not None: + job.status = DownloadStatus.completed.value + job.progress = 100.0 + job.file_path = library.to_stored_path(final) + job.bytes_done = library.file_size(final) or job.bytes_done + job.speed_bps = None + job.warning = warning + job.error = None + job.finished_at = datetime.now(timezone.utc) + await db.commit() + if previous is not None and previous.exists() and previous.resolve() != final.resolve(): + previous.unlink(missing_ok=True) - size = final_path.stat().st_size if final_path.exists() else None + # Optional extras — failures only produce a warning. + extra_warnings: list[str] = [] + try: + library.record_episode_metadata( + anime_dir, + anime_id=snap["anime_id"], + title=snap["title"], + season=snap["season"], + episode=snap["episode"], + voiceover=stream.voiceover, + file_name=final.name, + source=snap["source"], + quality=stream.video.quality, + ) + except OSError as exc: + extra_warnings.append(f"metadata.json not written: {exc}") + try: async with SessionLocal() as db: - existing = ( - await db.execute( - select(LibraryEpisode).where( - LibraryEpisode.anime_external_id == anime_id, - LibraryEpisode.season == season, - LibraryEpisode.episode == episode, - ) - ) + from backend.models import AnimeTitle + + title = ( + await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == snap["anime_id"])) ).scalar_one_or_none() - if existing: - existing.file_path = str(final_path) - existing.file_size = size - existing.voiceover = stream.voiceover - if episode_title: - existing.title = episode_title - else: - db.add( - LibraryEpisode( - anime_external_id=anime_id, - season=season, - episode=episode, - title=episode_title or f"Episode {episode}", - voiceover=stream.voiceover, - file_path=str(final_path), - file_size=size, - ) - ) - job_row = await db.get(DownloadJob, job_id) - if job_row: - job_row.status = DownloadStatus.completed.value - job_row.progress = 100 - job_row.file_path = str(final_path) - job_row.voiceover = stream.voiceover - await db.commit() - logger.info("Downloaded %s S%sE%s -> %s", anime_title, season, episode, final_path) - except Exception as exc: - logger.exception("Download failed job=%s: %s", job_id, exc) - await self._update( - job_id, - status=DownloadStatus.failed.value, - error=str(exc)[:2000], - ) + poster_url = title.poster if title else None + if poster_url: + from backend.player.sessions import validate_upstream + from backend.providers import get_registry - async def _download_hls( - self, - job_id: int, - url: str, - dest: Path, - headers: dict[str, str], - ) -> Path: - # Keep media suffix last so ffmpeg can select the output muxer. - temporary = dest.with_name(f"{dest.stem}.part{dest.suffix}") - temporary.unlink(missing_ok=True) - await self._update(job_id, progress=10) - await ffmpeg_tools.download_hls_with_ffmpeg(url, temporary, headers) - await self._update(job_id, progress=85) - return temporary - - async def _download_progressive( - self, - job_id: int, - url: str, - dest: Path, - headers: dict[str, str], - ) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - async with httpx.AsyncClient(follow_redirects=True, timeout=None) as client: - async with client.stream("GET", url, headers=headers) as resp: - resp.raise_for_status() - total = int(resp.headers.get("content-length") or 0) - done = 0 - with dest.open("wb") as f: - async for chunk in resp.aiter_bytes(1024 * 256): - if job_id in self._cancel_flags: - raise asyncio.CancelledError("cancelled") - f.write(chunk) - done += len(chunk) - if total: - pct = 5 + (done / total) * 80 - await self._update(job_id, progress=round(pct, 1)) + await validate_upstream(poster_url, proxy=get_registry().proxy) + await library.save_poster(anime_dir, poster_url, media_clients.get(get_registry().proxy)) + except Exception as exc: # noqa: BLE001 + extra_warnings.append(f"poster not saved: {exc}") + if extra_warnings: + await self._update(job_id, warning="; ".join(filter(None, [warning, *extra_warnings]))[:2000]) + + # ----------------------------------------------------------------- info + def running_ids(self) -> list[int]: + return list(self._running) download_manager = DownloadManager() + diff --git a/backend/main.py b/backend/main.py index 6854740..2d93fcb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ import asyncio import logging +import re import secrets from contextlib import asynccontextmanager from pathlib import Path @@ -10,18 +11,54 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.trustedhost import TrustedHostMiddleware -from backend.api.routes import router as api_router +from backend import __version__ +from backend.api import router as api_router from backend.config import PACKAGE_DIR, get_settings -from backend.database.session import init_db +from backend.database.session import SessionLocal, init_db from backend.downloader.manager import download_manager -from backend.player.proxy import router as proxy_router +from backend.player.proxy import router as media_router settings = get_settings() -logging.basicConfig( - level=getattr(logging, settings.log_level.upper(), logging.INFO), - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", -) + + +class _RedactSecrets(logging.Filter): + """Mask credentials in proxy URLs and signed query parameters before they reach logs.""" + + _patterns = ( + (re.compile(r"(\w+://)[^/\s:@]+:[^/\s@]+@"), r"\1***:***@"), + (re.compile(r"((?:token|sig|signature|key|hash)=)[^&\s]+", re.IGNORECASE), r"\1***"), + ) + + def filter(self, record: logging.LogRecord) -> bool: + try: + message = record.getMessage() + except Exception: # noqa: BLE001 + return True + redacted = message + for pattern, replacement in self._patterns: + redacted = pattern.sub(replacement, redacted) + if redacted != message: + record.msg, record.args = redacted, () + return True + + +def configure_logging(level: str | None = None) -> None: + root = logging.getLogger() + if not root.handlers: + logging.basicConfig( + level=getattr(logging, (level or settings.log_level).upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + for handler in root.handlers: + if not any(isinstance(f, _RedactSecrets) for f in handler.filters): + handler.addFilter(_RedactSecrets()) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("anicli-api").setLevel(logging.ERROR) + + +configure_logging() logger = logging.getLogger("animebox") FRONTEND_DIST = PACKAGE_DIR / "frontend" / "dist" @@ -49,7 +86,7 @@ def request_shutdown(token: str) -> bool: async def wait_for_shutdown() -> None: while _shutdown_event is None: - await asyncio.sleep(0) + await asyncio.sleep(0.05) await _shutdown_event.wait() @@ -61,37 +98,66 @@ def _frontend_ready() -> bool: return _frontend_index().is_file() +def allowed_hosts() -> list[str]: + hosts = {"127.0.0.1", "localhost", "::1", "[::1]", *settings.allowed_hosts} + if settings.host not in ("0.0.0.0", "::"): + hosts.add(settings.host) + return sorted(hosts) + + +async def _apply_saved_settings() -> None: + from backend.services.settings import apply_runtime, get_app_settings + + async with SessionLocal() as db: + await apply_runtime(await get_app_settings(db), network_changed=True) + + @asynccontextmanager async def lifespan(_app: FastAPI): global _shutdown_event _shutdown_event = asyncio.Event() await init_db() + await _apply_saved_settings() + download_manager.local_base_url = f"http://127.0.0.1:{settings.port}" await download_manager.start() if _frontend_ready(): - logger.info("AnimeBox started — library=%s ui=%s", settings.library_path, FRONTEND_DIST) + logger.info("AnimeBox %s started — library=%s", __version__, settings.library_path) else: logger.warning( - "AnimeBox started — library=%s, but frontend/dist is missing. " - "Build UI (scripts/run.ps1 / npm run build) or open Vite on :5173.", + "AnimeBox %s started — library=%s, but frontend/dist is missing. " + "Build the UI (scripts/run.* or: cd frontend && npm run build) or open Vite on :5173.", + __version__, settings.library_path, ) try: yield finally: await download_manager.stop() + from backend.providers import get_registry + from backend.providers.http import media_clients + + await get_registry().aclose() + await media_clients.aclose() + # Closing the last connection checkpoints the WAL into animebox.db, so copying + # only the .db file (e.g. when moving a portable folder) never loses recent data. + from backend.database.session import engine + + await engine.dispose() _shutdown_event = None -app = FastAPI(title="AnimeBox", version="0.1.0", lifespan=lifespan) +app = FastAPI(title="AnimeBox", version=__version__, lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins + [f"http://{settings.host}:{settings.port}"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_origins=settings.cors_origins, + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Content-Type", "Range", "X-AnimeBox-Token"], ) +# Rejects requests whose Host is not this machine: blocks DNS-rebinding pages from driving the API. +app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts()) app.include_router(api_router) -app.include_router(proxy_router) +app.include_router(media_router) _assets_dir = FRONTEND_DIST / "assets" if _assets_dir.is_dir(): @@ -99,10 +165,12 @@ async def lifespan(_app: FastAPI): async def _spa_response(full_path: str = "") -> FileResponse: + if full_path.startswith("api/"): + raise HTTPException(404, {"code": "not_found", "message": "Unknown API endpoint"}) if not _frontend_ready(): raise HTTPException( status_code=503, - detail="Frontend not built. Run scripts/run.ps1 (or: cd frontend && npm run build).", + detail="Frontend not built. Run scripts/run.ps1 / scripts/run.sh (or: cd frontend && npm run build).", ) if full_path: candidate = (FRONTEND_DIST / full_path).resolve() @@ -112,29 +180,24 @@ async def _spa_response(full_path: str = "") -> FileResponse: raise HTTPException(404, "Not Found") from exc if candidate.is_file(): return FileResponse(candidate) - return FileResponse(_frontend_index()) + return FileResponse(_frontend_index(), headers={"Cache-Control": "no-cache"}) -@app.get("/") +@app.get("/", include_in_schema=False) async def spa_root(): return await _spa_response("") -@app.get("/{full_path:path}") +@app.get("/{full_path:path}", include_in_schema=False) async def spa(full_path: str): - # API / proxy are registered above; this only catches UI routes. + # API and media routers are registered first; this only catches UI routes. return await _spa_response(full_path) def run() -> None: import uvicorn - uvicorn.run( - "backend.main:app", - host=settings.host, - port=settings.port, - reload=False, - ) + uvicorn.run("backend.main:app", host=settings.host, port=settings.port, reload=False) if __name__ == "__main__": diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 7334e65..8cd2d67 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,7 +1,20 @@ +"""SQLite schema. + +Separation of concerns (2.0): + +- ``anime_titles`` catalog metadata cache. Browsing writes here; it never implies offline content. +- ``library_episodes`` downloaded, validated media files. The offline library is derived from this table only. +- ``download_jobs`` durable download queue and history. +- ``watch_history`` playback progress for online and offline viewing. +- ``app_settings`` user preferences. + +Schema changes must ship with a step in ``backend/database/migrations.py``. +""" + from datetime import datetime from enum import StrEnum -from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func +from sqlalchemy import Boolean, DateTime, Float, Index, Integer, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from backend.database.session import Base @@ -9,12 +22,24 @@ class DownloadStatus(StrEnum): queued = "queued" + resolving = "resolving" downloading = "downloading" + processing = "processing" + paused = "paused" completed = "completed" failed = "failed" cancelled = "cancelled" +ACTIVE_DOWNLOAD_STATUSES = ( + DownloadStatus.queued.value, + DownloadStatus.resolving.value, + DownloadStatus.downloading.value, + DownloadStatus.processing.value, +) +OPEN_DOWNLOAD_STATUSES = (*ACTIVE_DOWNLOAD_STATUSES, DownloadStatus.paused.value) + + class AnimeTitle(Base): __tablename__ = "anime_titles" @@ -25,6 +50,7 @@ class AnimeTitle(Base): poster: Mapped[str | None] = mapped_column(String(1024), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) year: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Set only once content has been downloaded into the library. folder_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( @@ -34,6 +60,9 @@ class AnimeTitle(Base): class LibraryEpisode(Base): __tablename__ = "library_episodes" + __table_args__ = ( + UniqueConstraint("anime_external_id", "season", "episode", name="ux_library_episodes_key"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) anime_external_id: Mapped[str] = mapped_column(String(512), index=True) @@ -41,8 +70,10 @@ class LibraryEpisode(Base): episode: Mapped[int] = mapped_column(Integer) title: Mapped[str | None] = mapped_column(String(512), nullable=True) voiceover: Mapped[str | None] = mapped_column(String(256), nullable=True) + # Relative to the library root when stored inside it; absolute otherwise. file_path: Mapped[str] = mapped_column(String(1024)) file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + quality: Mapped[int | None] = mapped_column(Integer, nullable=True) downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) progress_seconds: Mapped[float] = mapped_column(Float, default=0.0) duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) @@ -51,6 +82,7 @@ class LibraryEpisode(Base): class DownloadJob(Base): __tablename__ = "download_jobs" + __table_args__ = (Index("ix_download_jobs_episode_key", "anime_external_id", "season", "episode"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) anime_external_id: Mapped[str] = mapped_column(String(512), index=True) @@ -64,16 +96,29 @@ class DownloadJob(Base): status: Mapped[str] = mapped_column(String(32), default=DownloadStatus.queued.value, index=True) progress: Mapped[float] = mapped_column(Float, default=0.0) error: Mapped[str | None] = mapped_column(Text, nullable=True) + warning: Mapped[str | None] = mapped_column(Text, nullable=True) file_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) steam_deck: Mapped[bool] = mapped_column(Boolean, default=False) + quality: Mapped[int | None] = mapped_column(Integer, nullable=True) + player_host: Mapped[str | None] = mapped_column(String(256), nullable=True) + bytes_done: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + bytes_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + speed_bps: Mapped[float | None] = mapped_column(Float, nullable=True) + attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + resumable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) class WatchHistory(Base): __tablename__ = "watch_history" + __table_args__ = ( + UniqueConstraint("anime_external_id", "season", "episode", name="ux_watch_history_key"), + Index("ix_watch_history_updated_at", "updated_at"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) anime_external_id: Mapped[str] = mapped_column(String(512), index=True) @@ -81,6 +126,7 @@ class WatchHistory(Base): poster: Mapped[str | None] = mapped_column(String(1024), nullable=True) season: Mapped[int] = mapped_column(Integer, default=1) episode: Mapped[int] = mapped_column(Integer) + voiceover: Mapped[str | None] = mapped_column(String(256), nullable=True) progress_seconds: Mapped[float] = mapped_column(Float, default=0.0) duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) local_episode_id: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/backend/player/proxy.py b/backend/player/proxy.py index 7afa5eb..db53727 100644 --- a/backend/player/proxy.py +++ b/backend/player/proxy.py @@ -1,118 +1,140 @@ -"""Reverse proxy for browser playback — inspired by ani-cli-ru reverse_proxy patterns.""" +"""Media proxy for browser playback and ffmpeg downloads. + +The HLS rewriting approach (route playlist entries and ``URI=`` attributes back +through a local endpoint) is inspired by ani-cli-ru's reverse proxy +(GPL-3.0, https://github.com/vypivshiy/ani-cli-ru). This implementation is +AnimeBox's own: entries are mapped to session-scoped tokens instead of raw URLs. +""" from __future__ import annotations import logging import re -from urllib.parse import quote, urljoin +from urllib.parse import urljoin, urlsplit import httpx -from fastapi import APIRouter, HTTPException, Query, Request, Response -from fastapi.responses import StreamingResponse - -logger = logging.getLogger("animebox.player") - -router = APIRouter(prefix="/api/proxy", tags=["proxy"]) - -_HEADER_STORE: dict[str, dict[str, str]] = {} - - -def store_headers(headers: dict[str, str]) -> str: - import uuid +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse - hid = uuid.uuid4().hex - _HEADER_STORE[hid] = headers - return hid +from backend.player.sessions import MediaSession, UnsafeUpstream, media_sessions, validate_upstream +from backend.providers.http import media_clients +logger = logging.getLogger("animebox.player") -def _proxy_url(url: str, hid: str | None) -> str: - proxied = f"/api/proxy/stream?url={quote(url, safe='')}" - if hid: - proxied += f"&hid={hid}" - return proxied +router = APIRouter(prefix="/api/media", tags=["media"]) +MAX_PLAYLIST_BYTES = 8 * 1024 * 1024 +MAX_REDIRECTS = 5 +_PASS_HEADERS = ("content-type", "content-length", "content-encoding", "content-range", "accept-ranges", "last-modified", "etag") +_URI_ATTR = re.compile(r'(URI=)(["\'])([^"\']+)(\2)') -def _rewrite_m3u8(body: str, base_url: str, hid: str | None) -> str: - """Rewrite HLS media URLs and URI attributes through local proxy. - Adapted from ani-cli-ru's GPL-3.0 reverse proxy implementation. - See THIRD_PARTY.md. - """ - uri_pattern = re.compile(r'(URI=["\'])([^"\']+)(["\'])') +def rewrite_playlist(body: str, base_url: str, session: MediaSession) -> str: lines: list[str] = [] for line in body.splitlines(): stripped = line.strip() if not stripped: lines.append(line) - continue - if not stripped.startswith("#"): - lines.append(_proxy_url(urljoin(base_url, stripped), hid)) - continue - if "URI=" in line: - line = uri_pattern.sub( - lambda match: ( - f"{match.group(1)}" - f"{_proxy_url(urljoin(base_url, match.group(2)), hid)}" - f"{match.group(3)}" - ), - line, + elif not stripped.startswith("#"): + lines.append(session.url_for(urljoin(base_url, stripped))) + elif "URI=" in line: + lines.append( + _URI_ATTR.sub( + lambda m: f"{m.group(1)}{m.group(2)}{session.url_for(urljoin(base_url, m.group(3)))}{m.group(4)}", + line, + ) ) - lines.append(line) + else: + lines.append(line) return "\n".join(lines) + "\n" -@router.get("/stream") -async def proxy_stream( - request: Request, - url: str = Query(...), - hid: str | None = Query(None), -): - headers = dict(_HEADER_STORE.get(hid or "", {})) - # Forward Range for seeking - if range_header := request.headers.get("range"): +def _error(status: int, code: str, message: str) -> JSONResponse: + return JSONResponse({"detail": {"code": code, "message": message}}, status_code=status) + + +def _looks_like_playlist(url: str, content_type: str, length: int | None) -> bool: + content_type = content_type.lower() + if "mpegurl" in content_type: + return True + if urlsplit(url).path.lower().endswith(".m3u8"): + return True + return (content_type.startswith("text/") or "octet-stream" in content_type) and length is not None and length < 1024 * 1024 + + +async def _open_upstream(session: MediaSession, url: str, range_header: str | None) -> tuple[httpx.Response, str]: + client = media_clients.get(session.proxy) + headers = dict(session.headers) + if range_header: headers["Range"] = range_header + for _ in range(MAX_REDIRECTS + 1): + await validate_upstream(url, proxy=session.proxy) + response = await client.send(client.build_request("GET", url, headers=headers), stream=True) + if response.status_code in (301, 302, 303, 307, 308) and response.headers.get("location"): + await response.aclose() + url = urljoin(url, response.headers["location"]) + continue + return response, url + raise UnsafeUpstream("too many redirects") + + +@router.get("/{session_id}/{token}/{name}") +async def media(session_id: str, token: str, name: str, request: Request): # noqa: ARG001 - name is cosmetic + session = media_sessions.get(session_id) + if session is None: + return _error(410, "stream_expired", "Media session expired; resolve the stream again") + url = session.entries.get(token) + if url is None: + return _error(404, "unknown_media", "Unknown media entry") - client = httpx.AsyncClient(follow_redirects=True, timeout=60) try: - req = client.build_request("GET", url, headers=headers) - upstream = await client.send(req, stream=True) - except Exception as exc: - await client.aclose() - raise HTTPException(502, f"Upstream error: {exc}") from exc + upstream, final_url = await _open_upstream(session, url, request.headers.get("range")) + except UnsafeUpstream as exc: + logger.warning("Blocked media request to %s: %s", urlsplit(url).netloc, exc) + return _error(403, "blocked_upstream", f"Blocked upstream: {exc}") + except httpx.TimeoutException: + return _error(504, "upstream_timeout", "Media host did not respond in time") + except httpx.HTTPError as exc: + return _error(502, "upstream_error", f"Media host unreachable: {type(exc).__name__}") if upstream.status_code >= 400: - body = await upstream.aread() await upstream.aclose() - await client.aclose() - raise HTTPException(upstream.status_code, body.decode("utf-8", errors="ignore")[:500]) + code = "stream_expired" if upstream.status_code in (401, 403, 404, 410) else "upstream_error" + return _error(502, code, f"Media host answered HTTP {upstream.status_code}") content_type = upstream.headers.get("content-type", "application/octet-stream") - is_playlist = "mpegurl" in content_type or url.endswith(".m3u8") - - if is_playlist: - raw = await upstream.aread() - await upstream.aclose() - await client.aclose() - text = raw.decode("utf-8", errors="ignore") - rewritten = _rewrite_m3u8(text, url, hid) - return Response(content=rewritten, media_type="application/vnd.apple.mpegurl") + length_header = upstream.headers.get("content-length") + length = int(length_header) if length_header and length_header.isdigit() else None - async def iter_bytes(): + if upstream.status_code == 200 and _looks_like_playlist(final_url, content_type, length): try: + raw = bytearray() async for chunk in upstream.aiter_bytes(): + raw.extend(chunk) + if len(raw) > MAX_PLAYLIST_BYTES: + return _error(502, "upstream_error", "Playlist too large") + except httpx.HTTPError as exc: + return _error(502, "upstream_error", f"Playlist download failed: {type(exc).__name__}") + finally: + await upstream.aclose() + if bytes(raw[:16]).lstrip(b"\xef\xbb\xbf").startswith(b"#EXTM3U"): + text = raw.decode("utf-8", errors="replace") + return Response( + content=rewrite_playlist(text, final_url, session), + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-store"}, + ) + return Response(content=bytes(raw), media_type=content_type) + + async def body(): + try: + async for chunk in upstream.aiter_raw(256 * 1024): yield chunk + except httpx.HTTPError as exc: + logger.info("Media stream interrupted: %s", type(exc).__name__) finally: await upstream.aclose() - await client.aclose() - - out_headers = {} - for key in ("content-length", "content-range", "accept-ranges"): - if key in upstream.headers: - out_headers[key] = upstream.headers[key] - - return StreamingResponse( - iter_bytes(), - status_code=upstream.status_code, - media_type=content_type, - headers=out_headers, - ) + + headers = {key: upstream.headers[key] for key in _PASS_HEADERS if key in upstream.headers} + headers.pop("content-type", None) + return StreamingResponse(body(), status_code=upstream.status_code, media_type=content_type, headers=headers) diff --git a/backend/player/sessions.py b/backend/player/sessions.py new file mode 100644 index 0000000..c5b47f0 --- /dev/null +++ b/backend/player/sessions.py @@ -0,0 +1,163 @@ +"""Scoped media sessions for the playback/download proxy. + +A session is created only by the backend after it resolved a stream. It holds +the upstream request headers and a table ``token -> upstream URL``. Clients can +fetch only tokens the server itself issued (the resolved URL and URLs found in +playlists it rewrote), so the proxy cannot be pointed at arbitrary hosts. +Every upstream hop is additionally checked against loopback/private networks. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import logging +import re +import secrets +import socket +import time +from collections import OrderedDict +from dataclasses import dataclass, field +from urllib.parse import unquote, urlsplit + +logger = logging.getLogger("animebox.media") + +MAX_SESSIONS = 300 +MAX_ENTRIES_PER_SESSION = 200_000 +_DNS_TTL = 300.0 + + +class UnsafeUpstream(ValueError): + pass + + +@dataclass +class MediaSession: + id: str + headers: dict[str, str] + proxy: str | None + ttl: float + created_at: float = field(default_factory=time.time) + last_used: float = field(default_factory=time.time) + entries: dict[str, str] = field(default_factory=dict) + _reverse: dict[str, str] = field(default_factory=dict, repr=False) + + def token_for(self, url: str) -> str: + token = self._reverse.get(url) + if token is None: + if len(self.entries) >= MAX_ENTRIES_PER_SESSION: + raise UnsafeUpstream("media session entry limit reached") + token = secrets.token_urlsafe(9) + self.entries[token] = url + self._reverse[url] = token + return token + + def url_for(self, url: str) -> str: + return f"/api/media/{self.id}/{self.token_for(url)}/{media_filename(url)}" + + @property + def expires_at(self) -> float: + return self.last_used + self.ttl + + def expired(self, now: float | None = None) -> bool: + return (now or time.time()) > self.expires_at + + +def media_filename(url: str) -> str: + """Last path segment (keeps the extension ffmpeg's HLS demuxer checks).""" + name = unquote(urlsplit(url).path.rstrip("/").rsplit("/", 1)[-1]) or "media" + name = re.sub(r"[^A-Za-z0-9._-]+", "_", name)[-80:].strip("._") or "media" + return name + + +class MediaSessionStore: + def __init__(self) -> None: + self._sessions: OrderedDict[str, MediaSession] = OrderedDict() + + def create(self, headers: dict[str, str], proxy: str | None, ttl: float) -> MediaSession: + self.purge() + session = MediaSession(id=secrets.token_urlsafe(16), headers=dict(headers), proxy=proxy, ttl=ttl) + self._sessions[session.id] = session + while len(self._sessions) > MAX_SESSIONS: + self._sessions.popitem(last=False) + return session + + def get(self, session_id: str) -> MediaSession | None: + session = self._sessions.get(session_id) + if session is None: + return None + if session.expired(): + self._sessions.pop(session_id, None) + return None + session.last_used = time.time() + self._sessions.move_to_end(session_id) + return session + + def drop(self, session_id: str) -> None: + self._sessions.pop(session_id, None) + + def purge(self) -> int: + now = time.time() + expired = [sid for sid, s in self._sessions.items() if s.expired(now)] + for sid in expired: + self._sessions.pop(sid, None) + return len(expired) + + def __len__(self) -> int: + return len(self._sessions) + + +media_sessions = MediaSessionStore() + +_dns_cache: dict[str, tuple[float, bool]] = {} + + +async def _resolve(host: str, port: int) -> list[str]: + infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM) + return [info[4][0] for info in infos] + + +def _is_public(address: str) -> bool: + ip = ipaddress.ip_address(address.split("%", 1)[0]) + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: + ip = ip.ipv4_mapped + return ip.is_global and not ip.is_multicast + + +async def validate_upstream(url: str, *, proxy: str | None = None) -> None: + """Reject non-HTTP URLs and URLs pointing at loopback, private, link-local or reserved addresses.""" + parts = urlsplit(url) + if parts.scheme not in ("http", "https"): + raise UnsafeUpstream(f"unsupported scheme {parts.scheme!r}") + host = (parts.hostname or "").strip().lower() + if not host: + raise UnsafeUpstream("missing host") + if parts.username or parts.password: + raise UnsafeUpstream("credentials in URL are not allowed") + if host == "localhost" or host.endswith((".localhost", ".local", ".internal")): + raise UnsafeUpstream("local host names are not allowed") + try: + ipaddress.ip_address(host) + literal = True + except ValueError: + literal = False + if literal: + if not _is_public(host): + raise UnsafeUpstream("private or local address") + return + + cached = _dns_cache.get(host) + if cached and time.monotonic() - cached[0] < _DNS_TTL: + if not cached[1]: + raise UnsafeUpstream("host resolves to a private or local address") + return + try: + addresses = await _resolve(host, parts.port or 443) + except OSError: + if proxy: + return # only the configured proxy can resolve this name + raise UnsafeUpstream(f"cannot resolve {host}") from None + public = bool(addresses) and all(_is_public(address) for address in addresses) + _dns_cache[host] = (time.monotonic(), public) + if not public: + raise UnsafeUpstream("host resolves to a private or local address") diff --git a/backend/providers/__init__.py b/backend/providers/__init__.py index ceb29f5..b3ad29f 100644 --- a/backend/providers/__init__.py +++ b/backend/providers/__init__.py @@ -1,13 +1,12 @@ -from backend.providers.registry import ( - ProviderRegistry, - decode_external_id, - encode_external_id, - get_registry, -) - -__all__ = [ - "ProviderRegistry", - "decode_external_id", - "encode_external_id", - "get_registry", -] +"""Provider layer. ``ids`` is import-light; the registry pulls in anicli-api.""" + +from backend.providers.ids import canonical_id, decode_external_id, encode_external_id, safe_canonical_id + + +def get_registry(): + from backend.providers.registry import get_registry as _get + + return _get() + + +__all__ = ["canonical_id", "decode_external_id", "encode_external_id", "get_registry", "safe_canonical_id"] diff --git a/backend/providers/cache.py b/backend/providers/cache.py new file mode 100644 index 0000000..e7d948a --- /dev/null +++ b/backend/providers/cache.py @@ -0,0 +1,122 @@ +"""Caches. + +``SearchCache`` persists the minimal data needed to rebuild an anicli-api +``Search`` object for an id (URL + provider payload). ``TTLCache`` keeps +short-lived details, episode lists and player lists in memory so opening a +title, picking a voiceover and starting playback do not refetch the same pages. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +import time +from collections import OrderedDict +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Generic, TypeVar + +logger = logging.getLogger("animebox.cache") + +SEARCH_TTL = 30 * 24 * 3600 +T = TypeVar("T") + + +@dataclass +class CachedSearch: + source: str + url: str + title: str + thumbnail: str + payload: dict[str, Any] + saved_at: float + + +class SearchCache: + def __init__(self, directory: Path, ttl: float = SEARCH_TTL, max_memory: int = 2000) -> None: + self.directory = directory + self.ttl = ttl + self._mem: OrderedDict[str, CachedSearch] = OrderedDict() + self._max_memory = max_memory + + def _path(self, external_id: str) -> Path: + safe = re.sub(r"[^\w.-]+", "_", external_id)[:120] + digest = hashlib.sha1(external_id.encode("utf-8")).hexdigest()[:10] + return self.directory / f"{safe}-{digest}.json" + + def _legacy_path(self, external_id: str) -> Path: + return self.directory / f"{re.sub(r'[^\w.-]+', '_', external_id)[:180]}.json" + + def put(self, external_id: str, entry: CachedSearch) -> None: + self._mem[external_id] = entry + self._mem.move_to_end(external_id) + while len(self._mem) > self._max_memory: + self._mem.popitem(last=False) + try: + self.directory.mkdir(parents=True, exist_ok=True) + self._path(external_id).write_text(json.dumps(asdict(entry), ensure_ascii=False), encoding="utf-8") + except (OSError, TypeError, ValueError) as exc: + logger.debug("Failed to persist search cache: %s", exc) + + def get(self, external_id: str) -> CachedSearch | None: + now = time.time() + entry = self._mem.get(external_id) + if entry and now - entry.saved_at <= self.ttl: + return entry + for path in (self._path(external_id), self._legacy_path(external_id)): + if not path.is_file(): + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + entry = CachedSearch( + source=raw["source"], + url=raw["url"], + title=raw["title"], + thumbnail=raw.get("thumbnail") or "", + payload=raw.get("payload") or {}, + saved_at=float(raw.get("saved_at") or 0), + ) + except (OSError, ValueError, KeyError, TypeError): + continue + if now - entry.saved_at > self.ttl: + return None + self._mem[external_id] = entry + return entry + return None + + def invalidate(self, external_id: str) -> None: + self._mem.pop(external_id, None) + for path in (self._path(external_id), self._legacy_path(external_id)): + path.unlink(missing_ok=True) + + +class TTLCache(Generic[T]): + def __init__(self, ttl: float, max_items: int = 256) -> None: + self.ttl = ttl + self.max_items = max_items + self._items: OrderedDict[Any, tuple[float, T]] = OrderedDict() + + def get(self, key: Any) -> T | None: + item = self._items.get(key) + if item is None: + return None + saved_at, value = item + if time.monotonic() - saved_at > self.ttl: + self._items.pop(key, None) + return None + self._items.move_to_end(key) + return value + + def set(self, key: Any, value: T) -> None: + self._items[key] = (time.monotonic(), value) + self._items.move_to_end(key) + while len(self._items) > self.max_items: + self._items.popitem(last=False) + + def pop(self, key: Any) -> None: + self._items.pop(key, None) + + def clear(self) -> None: + self._items.clear() diff --git a/backend/providers/catalog.py b/backend/providers/catalog.py new file mode 100644 index 0000000..92065f8 --- /dev/null +++ b/backend/providers/catalog.py @@ -0,0 +1,98 @@ +"""Supported sources and their maturity. + +Only sources listed here are exposed. A new anicli-api module is not shown to +users until it is added and verified, so an upstream update cannot silently add +a broken provider to the UI. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SourceSpec: + id: str + label: str + module: str + stable: bool + enabled_by_default: bool + check_query: str + note: str = "" + + +SOURCES: tuple[SourceSpec, ...] = ( + SourceSpec( + id="animego", + label="AnimeGO", + module="animego", + stable=True, + enabled_by_default=True, + check_query="Врата Штейна", + note="Рекомендуется. У каждой озвучки несколько плееров (Kodik, Aniboom, CVH) — при сбое AnimeBox переключается на следующий.", + ), + SourceSpec( + id="anilibria", + label="AniLiberty", + module="anilibria", + stable=False, + enabled_by_default=True, + check_query="Steins", + note="API AniLibria/AniLiberty. Только собственная озвучка.", + ), + SourceSpec( + id="animevost", + label="AnimeVost", + module="animevost", + stable=False, + enabled_by_default=True, + check_query="Врата Штейна", + note="API AnimeVost. Только собственная озвучка, прямые MP4-файлы (поддерживают паузу загрузки).", + ), + SourceSpec( + id="yummy_anime", + label="Yummy Anime", + module="yummy_anime", + stable=False, + enabled_by_default=True, + check_query="Врата Штейна", + note="Часть плееров отдаёт только DASH: такие серии можно скачать, но не смотреть в браузере.", + ), + SourceSpec( + id="anilibme", + label="AniLib", + module="anilibme", + stable=False, + enabled_by_default=False, + check_query="Steins", + note="Часто медленный или пустой вне своего региона.", + ), + SourceSpec( + id="dreamcast", + label="DreamCast", + module="dreamcast", + stable=False, + enabled_by_default=False, + check_query="Врата Штейна", + note="Небольшой каталог.", + ), + SourceSpec( + id="sameband", + label="SameBand", + module="sameband", + stable=False, + enabled_by_default=False, + check_query="Врата Штейна", + note="Небольшой каталог.", + ), +) + +# Not exposed: hdrezka (relies on an anti-bot User-Agent workaround; not anime-focused). +EXCLUDED_MODULES = frozenset({"hdrezka"}) + +SOURCE_BY_ID: dict[str, SourceSpec] = {spec.id: spec for spec in SOURCES} +DEFAULT_SOURCE = "animego" + + +def default_enabled_sources() -> list[str]: + return [spec.id for spec in SOURCES if spec.enabled_by_default] diff --git a/backend/providers/errors.py b/backend/providers/errors.py new file mode 100644 index 0000000..fe46ff4 --- /dev/null +++ b/backend/providers/errors.py @@ -0,0 +1,55 @@ +"""Provider error taxonomy. Each error carries the pipeline stage that failed.""" + +from __future__ import annotations + +STAGES = ("search", "details", "episodes", "voiceovers", "stream") + + +class ProviderError(Exception): + code = "provider_error" + http_status = 502 + + def __init__(self, message: str, *, source: str | None = None, stage: str | None = None) -> None: + super().__init__(message) + self.message = message + self.source = source + self.stage = stage + + def to_detail(self) -> dict: + return {"code": self.code, "message": self.message, "source": self.source, "stage": self.stage} + + +class ProviderTimeout(ProviderError): + code = "provider_timeout" + http_status = 504 + + +class ProviderUnavailable(ProviderError): + """Source disabled, unknown, or failed to load.""" + + code = "provider_unavailable" + http_status = 503 + + +class NotFound(ProviderError, LookupError): + code = "not_found" + http_status = 404 + + +class CacheExpired(NotFound): + code = "search_expired" + http_status = 410 + + +class StreamUnavailable(ProviderError): + code = "stream_unavailable" + http_status = 502 + + def __init__(self, message: str, *, attempts: list[dict] | None = None, **kwargs) -> None: + super().__init__(message, **kwargs) + self.attempts = attempts or [] + + def to_detail(self) -> dict: + detail = super().to_detail() + detail["attempts"] = self.attempts + return detail diff --git a/backend/providers/health.py b/backend/providers/health.py new file mode 100644 index 0000000..87182d1 --- /dev/null +++ b/backend/providers/health.py @@ -0,0 +1,100 @@ +"""Provider health derived from real operations and explicit diagnostics. + +A provider is reported ``ok`` only when every pipeline stage (search, details, +episodes, voiceovers, stream) has most recently succeeded. Importability alone +proves nothing. +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from backend.providers.errors import STAGES + +logger = logging.getLogger("animebox.health") + + +@dataclass +class StageResult: + ok: bool + at: float + latency_ms: int | None = None + error: str | None = None + + +@dataclass +class SourceHealth: + stages: dict[str, StageResult] = field(default_factory=dict) + last_check_at: float | None = None + + @property + def status(self) -> str: + results = [self.stages.get(stage) for stage in STAGES] + search = self.stages.get("search") + if any(r is not None and not r.ok for r in results): + return "down" if search is not None and not search.ok else "degraded" + if all(r is not None and r.ok for r in results): + return "ok" + if any(r is not None for r in results): + return "partial" + return "unknown" + + +class HealthTracker: + def __init__(self, path: Path | None = None) -> None: + self._path = path + self._lock = threading.Lock() + self._sources: dict[str, SourceHealth] = {} + self._dirty_since: float | None = None + self._load() + + def _load(self) -> None: + if not self._path or not self._path.is_file(): + return + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + for source, data in raw.items(): + stages = {name: StageResult(**value) for name, value in data.get("stages", {}).items() if name in STAGES} + self._sources[source] = SourceHealth(stages=stages, last_check_at=data.get("last_check_at")) + except (OSError, ValueError, TypeError) as exc: + logger.debug("Ignoring unreadable provider health file: %s", exc) + + def _save(self) -> None: + if not self._path: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + data = { + source: { + "stages": {name: asdict(result) for name, result in health.stages.items()}, + "last_check_at": health.last_check_at, + } + for source, health in self._sources.items() + } + tmp = self._path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + tmp.replace(self._path) + except OSError as exc: + logger.debug("Could not persist provider health: %s", exc) + + def record(self, source: str, stage: str, ok: bool, latency_ms: int | None = None, error: str | None = None) -> None: + if stage not in STAGES: + return + with self._lock: + health = self._sources.setdefault(source, SourceHealth()) + health.stages[stage] = StageResult(ok=ok, at=time.time(), latency_ms=latency_ms, error=(error or None) and error[:300]) + self._save() + + def mark_checked(self, source: str) -> None: + with self._lock: + self._sources.setdefault(source, SourceHealth()).last_check_at = time.time() + self._save() + + def get(self, source: str) -> SourceHealth: + with self._lock: + return self._sources.get(source) or SourceHealth() diff --git a/backend/providers/http.py b/backend/providers/http.py new file mode 100644 index 0000000..a0f82fd --- /dev/null +++ b/backend/providers/http.py @@ -0,0 +1,148 @@ +"""HTTP clients for providers and media. + +- One long-lived client pair per source: cookies and connection pools stay + scoped to that source and never leak into another provider. +- Connection failures are retried once. HTTP 403/429 and anti-bot pages are + *final* answers: AnimeBox respects source access restrictions and does not + retry or work around them (anicli-api's default transport retries 10 times). +- The configured network proxy applies to every request that may produce or + consume a signed media URL (resolution, playback proxy, downloads). OKCDN + links embed the requesting IP (``srcIp``); mixing proxied resolution with + direct playback was the cause of intermittent 403s in 1.x. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import httpx +from anicli_api._http import HEADERS as ANICLI_HEADERS +from anicli_api._http import BaseHTTPAsync, BaseHTTPSync + +logger = logging.getLogger("animebox.http") + +# Identity used for extraction. Media requests reuse the exact same User-Agent so a +# CDN that ties signed links to the requesting client sees one consistent client. +EXTRACTION_HEADERS: dict[str, str] = dict(ANICLI_HEADERS) +DEFAULT_USER_AGENT = EXTRACTION_HEADERS["User-Agent"] +DEFAULT_TIMEOUT = httpx.Timeout(15.0, connect=8.0) +MEDIA_TIMEOUT = httpx.Timeout(30.0, connect=10.0) + + +class _RetryOnceAsyncTransport(httpx.AsyncHTTPTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + try: + return await super().handle_async_request(request) + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError) as exc: + logger.debug("Retrying %s %s after %s", request.method, request.url.host, type(exc).__name__) + await asyncio.sleep(0.4) + return await super().handle_async_request(request) + + +class _RetryOnceSyncTransport(httpx.HTTPTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + try: + return super().handle_request(request) + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError): + return super().handle_request(request) + + +def async_transport(proxy: str | None, http2: bool = True) -> httpx.AsyncHTTPTransport: + return _RetryOnceAsyncTransport(proxy=proxy or None, http2=http2) + + +def sync_transport(proxy: str | None, http2: bool = True) -> httpx.HTTPTransport: + return _RetryOnceSyncTransport(proxy=proxy or None, http2=http2) + + +def make_async_client(proxy: str | None, *, headers: dict[str, str] | None = None, **kwargs: Any) -> BaseHTTPAsync: + http2 = kwargs.pop("http2", True) + return BaseHTTPAsync( + transport=async_transport(proxy, http2=http2), + headers=dict(headers if headers is not None else EXTRACTION_HEADERS), + timeout=kwargs.pop("timeout", DEFAULT_TIMEOUT), + http2=http2, + **kwargs, + ) + + +def make_sync_client(proxy: str | None, *, headers: dict[str, str] | None = None, **kwargs: Any) -> BaseHTTPSync: + http2 = kwargs.pop("http2", True) + return BaseHTTPSync( + transport=sync_transport(proxy, http2=http2), + headers=dict(headers if headers is not None else EXTRACTION_HEADERS), + timeout=kwargs.pop("timeout", DEFAULT_TIMEOUT), + http2=http2, + **kwargs, + ) + + +class SourceClients: + """Lazily created client pair for one source.""" + + def __init__(self, proxy: str | None) -> None: + self.proxy = proxy + self._sync: BaseHTTPSync | None = None + self._async: BaseHTTPAsync | None = None + + @property + def sync(self) -> BaseHTTPSync: + if self._sync is None: + self._sync = make_sync_client(self.proxy) + return self._sync + + @property + def async_(self) -> BaseHTTPAsync: + if self._async is None or self._async.is_closed: + self._async = make_async_client(self.proxy) + return self._async + + async def aclose(self) -> None: + if self._async is not None: + await self._async.aclose() + if self._sync is not None: + self._sync.close() + self._async = self._sync = None + + +class MediaClientPool: + """Shared, pooled client for media proxying/downloads (one per network config).""" + + def __init__(self) -> None: + self._clients: dict[str | None, httpx.AsyncClient] = {} + + def get(self, proxy: str | None) -> httpx.AsyncClient: + client = self._clients.get(proxy) + if client is None or client.is_closed: + client = httpx.AsyncClient( + transport=async_transport(proxy, http2=False), + timeout=MEDIA_TIMEOUT, + follow_redirects=False, # redirects are validated hop by hop + headers={"User-Agent": DEFAULT_USER_AGENT}, + limits=httpx.Limits(max_connections=32, max_keepalive_connections=16), + ) + self._clients[proxy] = client + return client + + async def aclose(self) -> None: + clients, self._clients = list(self._clients.values()), {} + for client in clients: + await client.aclose() + + +media_clients = MediaClientPool() + + +def redact_proxy(proxy: str | None) -> str | None: + """Hide credentials in proxy URLs before logging or returning them in diagnostics.""" + if not proxy: + return proxy + try: + url = httpx.URL(proxy) + except Exception: # noqa: BLE001 + return "" + if url.userinfo: + return str(url.copy_with(username="***", password="***")) + return proxy diff --git a/backend/providers/ids.py b/backend/providers/ids.py new file mode 100644 index 0000000..e25a0a8 --- /dev/null +++ b/backend/providers/ids.py @@ -0,0 +1,38 @@ +"""Stable external identifiers: ``:``. + +1.x clients sometimes sent the same title as ``animego:%2Fanime%2Fx`` and +``animego:/anime/x`` (route params were decoded twice). Every id entering the +backend is passed through :func:`canonical_id` so both spellings map to one +record. +""" + +from __future__ import annotations + +import re +from urllib.parse import quote, unquote + +_SOURCE_RE = re.compile(r"^[a-z0-9_\-]{1,64}$") + + +def encode_external_id(source: str, key: str) -> str: + return f"{source}:{quote(key, safe='')}" + + +def decode_external_id(external_id: str) -> tuple[str, str]: + source, _, rest = (external_id or "").partition(":") + if not source or not rest or not _SOURCE_RE.match(source): + raise ValueError(f"Invalid anime id: {external_id!r}") + return source, unquote(rest) + + +def canonical_id(external_id: str) -> str: + source, key = decode_external_id(external_id) + return encode_external_id(source, key) + + +def safe_canonical_id(external_id: str) -> str: + """Canonical form, or the input unchanged when it is not a provider id (e.g. ``local:...``).""" + try: + return canonical_id(external_id) + except ValueError: + return external_id diff --git a/backend/providers/players.py b/backend/providers/players.py new file mode 100644 index 0000000..48b9983 --- /dev/null +++ b/backend/providers/players.py @@ -0,0 +1,218 @@ +"""Player (video host) handling: extraction dispatch, ranking and video selection. + +Extraction reuses anicli-api (MIT, https://github.com/vypivshiy/anicli-api) +decoders. AnimeBox adds: + +- per-call HTTP clients so player-specific headers (e.g. Aniboom ``Referer``) + never leak into other players or providers; +- an adapter for AnimeGO's current ``/cdn-iframe///?dubbing=`` + URLs, which anicli-api's ``CdnVideoHub`` decoder no longer matches; +- awaited variants of Yummy Anime's CVH iframe flow (upstream forgets an ``await``); +- ranking that prefers players that recently worked. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from anicli_api.base import BaseSource +from anicli_api.player import ALL_DECODERS +from anicli_api.player.cdnvideohub import a_video_playlist_from_vk_id +from anicli_api.player.parsers.cdnvideohub_parser import CdnVideoHubAPI, PageParseCdnVideoData + +from backend.providers.http import EXTRACTION_HEADERS, make_async_client, make_sync_client + +logger = logging.getLogger("animebox.players") + +BROWSER_TYPES = ("m3u8", "mp4", "webm") +DOWNLOAD_TYPES = ("m3u8", "mp4", "webm", "mpd") + +_ANIMEGO_CDN_IFRAME = re.compile(r"^https?://(?:www\.)?animego\.\w+/cdn-iframe/(\d+)/(\d+)/(\d+)/?(?:\?.*)?$") +_YUMMY_CVH_IFRAME = re.compile(r"/iframeCVH\.html\?") + +# Lower is better. Unknown players sit in the middle. +_BASE_RANK = {"kodik": 0, "aniboom": 1, "cvh": 2, "aksor": 3, "csst": 3, "libria": 0, "animevost": 0, "sibnet": 8} + + +def player_label(url: str) -> str: + host = (urlsplit(url).netloc or "").lower() + if _ANIMEGO_CDN_IFRAME.match(url) or _YUMMY_CVH_IFRAME.search(url) or "cdnvideohub" in host: + return "cvh" + for key in ("kodik", "aniboom", "sibnet", "aksor", "csst", "libria", "animevost", "animetop", "sovetromantica"): + if key in host: + return "animevost" if key == "animetop" else key + return host.removeprefix("www.") or "direct" + + +@dataclass +class _HostHealth: + failures: int = 0 + last_failure: float = 0.0 + last_success: float = 0.0 + + +class PlayerHealth: + """In-memory record of which players recently worked. Failures decay after 30 minutes.""" + + DECAY = 30 * 60 + + def __init__(self) -> None: + self._hosts: dict[str, _HostHealth] = {} + + def success(self, label: str) -> None: + entry = self._hosts.setdefault(label, _HostHealth()) + entry.failures = 0 + entry.last_success = time.monotonic() + + def failure(self, label: str) -> None: + entry = self._hosts.setdefault(label, _HostHealth()) + entry.failures += 1 + entry.last_failure = time.monotonic() + + def penalty(self, label: str) -> int: + entry = self._hosts.get(label) + if not entry or not entry.failures: + return 0 + if time.monotonic() - entry.last_failure > self.DECAY: + return 0 + return 10 * min(entry.failures, 5) + + def rank(self, label: str) -> int: + return _BASE_RANK.get(label, 5) + self.penalty(label) + + +player_health = PlayerHealth() + + +@dataclass +class PlayerSource: + """One voiceover offered by one player.""" + + voiceover: str + url: str + label: str + raw: Any = field(repr=False, default=None) + + +def normalize_voiceover(title: str) -> str: + title = re.sub(r"^\s*озвучка\s+", "", title or "", flags=re.IGNORECASE).strip() + return title or "Unknown" + + +def voiceover_kind(title: str) -> str: + lowered = title.casefold() + if "субтитр" in lowered or "subtitle" in lowered or lowered in {"sub", "subs", "софтсаб"}: + return "subtitles" + if "оригинал" in lowered or "original" in lowered: + return "original" + return "dub" + + +def voiceover_key(title: str) -> str: + return re.sub(r"[\s\-_.]+", "", normalize_voiceover(title).casefold()) + + +async def _animego_cdn_iframe(url: str, proxy: str | None) -> list[Any]: + """Adapted from anicli-api ``CdnVideoHub.a_parse`` (MIT) for AnimeGO's current URL scheme.""" + match = _ANIMEGO_CDN_IFRAME.match(url) + if not match: + return [] + _title_id, season, episode = (int(part) for part in match.groups()) + dubbing = (parse_qs(urlsplit(url).query).get("dubbing") or [""])[0] + origin = f"{urlsplit(url).scheme}://{urlsplit(url).netloc}" + async with make_async_client(proxy) as client: + response = await client.get(url, headers={"referer": origin}) + response.raise_for_status() + options = PageParseCdnVideoData(response.text).parse() + result = await CdnVideoHubAPI.async_get_params_from_page( + client, pub=options["data_publisher_id"], aggr=options["data_aggregator"], id=options["data_title_id"] + ) + if not result.is_ok: + raise RuntimeError("CVH catalog request failed") + wanted = voiceover_key(dubbing) + for item in result.value["items"]: + if item["episode"] == episode and item["season"] == season and voiceover_key(item["voiceStudio"]) == wanted: + return await a_video_playlist_from_vk_id(client, item["vkId"]) + return [] + + +async def _yummy_cvh_iframe(source: Any, proxy: str | None) -> list[Any]: + """Awaited variant of anicli-api ``yummy_anime.Source.a_get_videos`` (MIT) CVH branch.""" + cls = type(source) + async with make_async_client(proxy) as client: + page = await client.get(source.url) + js_url = cls._get_js_url(urlsplit(source.url).netloc, page.text) + anime_id, episode, dubbing_code = cls._extract_iframe_params(source.url) + script = await client.get(js_url) + pub_id, aggr = cls._extract_script_params(script.text) + result = await CdnVideoHubAPI.async_get_params_from_page(client, pub=pub_id, aggr=aggr, id=anime_id) + if not result.is_ok: + raise RuntimeError("CVH catalog request failed") + vkid = cls._cdnvideohub_extract_vkid_cadidate(result.value, episode, dubbing_code) + return await a_video_playlist_from_vk_id(client, vkid) + + +async def _decoder(url: str, proxy: str | None) -> list[Any] | None: + for decoder in ALL_DECODERS: + if not decoder._compare_url(url): + continue + config = dict(getattr(decoder, "DEFAULT_HTTP_CONFIG", {}) or {}) + headers = {**EXTRACTION_HEADERS, **(config.pop("headers", {}) or {})} + a_http = make_async_client(proxy, headers=headers, **config) + s_http = make_sync_client(proxy, headers=headers, **config) + try: + return list(await decoder(http=s_http, a_http=a_http).a_parse(url)) + finally: + await a_http.aclose() + s_http.close() + return None + + +async def extract_videos(source: Any, proxy: str | None) -> list[Any]: + """Return anicli ``Video`` objects for one player source. Raises on extractor failure.""" + url = getattr(source, "url", "") or "" + vk_id = getattr(source, "cdn_videohub_vk_id", None) + if vk_id: + async with make_async_client(proxy) as client: + return list(await a_video_playlist_from_vk_id(client, vk_id)) + if _ANIMEGO_CDN_IFRAME.match(url): + return list(await _animego_cdn_iframe(url, proxy)) + if _YUMMY_CVH_IFRAME.search(url) and hasattr(type(source), "_get_js_url"): + return list(await _yummy_cvh_iframe(source, proxy)) + decoded = await _decoder(url, proxy) + if decoded is not None: + return decoded + if type(source).a_get_videos is not BaseSource.a_get_videos: + result = source.a_get_videos() + while inspect.isawaitable(result): # some upstream sources return an un-awaited coroutine + result = await result + return list(result or []) + raise LookupError(f"No extractor for player {player_label(url)}") + + +def pick_video(videos: list[Any], quality: int, allowed_types: tuple[str, ...]) -> Any | None: + """Closest quality not above ``quality`` (else the lowest above), preferring HLS for equal quality.""" + candidates = [v for v in videos if getattr(v, "type", None) in allowed_types and getattr(v, "url", None)] + if not candidates: + return None + type_rank = {kind: i for i, kind in enumerate(allowed_types)} + at_or_below = [v for v in candidates if int(v.quality) <= quality] + pool = at_or_below or candidates + target = max(int(v.quality) for v in pool) if at_or_below else min(int(v.quality) for v in pool) + same = [v for v in pool if int(v.quality) == target] + return min(same, key=lambda v: type_rank.get(v.type, 99)) + + +def available_qualities(videos: list[Any], allowed_types: tuple[str, ...]) -> list[int]: + return sorted({int(v.quality) for v in videos if v.type in allowed_types and int(v.quality) > 0}, reverse=True) + + +async def with_timeout(coro, timeout: float): + return await asyncio.wait_for(coro, timeout=timeout) diff --git a/backend/providers/registry.py b/backend/providers/registry.py index 5e2c22b..3583eb1 100644 --- a/backend/providers/registry.py +++ b/backend/providers/registry.py @@ -1,33 +1,62 @@ -"""Provider layer — thin adapters over anicli-api (MIT). +"""Provider registry — orchestrates anicli-api (MIT) extractors. -Inspired by Hakuneko connector registry and ani-cli-ru extractor loading. +Inspired by HakuNeko's connector registry and ani-cli-ru's source selection flow. + +Pipeline per title: search -> details -> episodes -> voiceovers (players) -> stream. +Each stage has its own timeout, records provider health, and raises a typed +:mod:`backend.providers.errors` exception. A failing provider never blocks the +others: search fans out concurrently and reports per-provider status. """ from __future__ import annotations import asyncio import importlib -import json import logging import re import time -from dataclasses import dataclass -from functools import lru_cache -from pathlib import Path +from collections import OrderedDict +from dataclasses import dataclass, field from typing import Any -from urllib.parse import quote, unquote, urlparse +from urllib.parse import urlsplit + +from backend.config import Settings, get_settings +from backend.providers import players +from backend.providers.cache import CachedSearch, SearchCache, TTLCache +from backend.providers.catalog import DEFAULT_SOURCE, SOURCE_BY_ID, SOURCES, SourceSpec, default_enabled_sources +from backend.providers.errors import ( + CacheExpired, + NotFound, + ProviderError, + ProviderTimeout, + ProviderUnavailable, + StreamUnavailable, +) +from backend.providers.health import HealthTracker +from backend.providers.http import DEFAULT_USER_AGENT, SourceClients +from backend.providers.ids import decode_external_id, encode_external_id +from backend.schemas import ( + AnimeDetail, + EpisodeInfo, + ProviderSearchStatus, + SearchResponse, + SearchResult, + VoiceoverOption, +) -from anicli_api._http import HTTPAsync, HTTPSync -from anicli_api.tools.helpers import get_video_by_quality +logger = logging.getLogger("animebox.providers") -from backend.config import ROOT_DIR, Settings, get_settings -from backend.schemas import AnimeDetail, EpisodeInfo, SearchResult, VideoStream, VoiceoverOption +DETAIL_TTL = 15 * 60 +SOURCES_TTL = 10 * 60 +MAX_AUTO_VOICEOVER_GROUPS = 4 -logger = logging.getLogger("animebox.providers") -DEFAULT_SOURCES = ("animego", "anilibria", "animevost", "yummy_anime") -CACHE_TTL = 12 * 3600 -CACHE_DIR = ROOT_DIR / "data" / "search_cache" +@dataclass +class ResolvedVideo: + type: str + quality: int + url: str + headers: dict[str, str] = field(default_factory=dict) @dataclass @@ -35,377 +64,453 @@ class ResolvedStream: anime_title: str episode: int voiceover: str - video: VideoStream - source_url: str + player: str + video: ResolvedVideo + qualities: list[int] + browser_playable: bool + source: str @dataclass -class CachedSearch: - source: str - url: str +class VoiceoverGroup: title: str - thumbnail: str - payload: dict[str, Any] - saved_at: float - - -def encode_external_id(source: str, key: str) -> str: - return f"{source}:{quote(key, safe='')}" - + players: list[players.PlayerSource] -def decode_external_id(external_id: str) -> tuple[str, str]: - source, _, rest = external_id.partition(":") - if not source or not rest: - raise ValueError(f"Invalid anime id: {external_id}") - return source, unquote(rest) - -def _stable_key(source: str, item: Any) -> str: +def _stable_key(item: Any) -> str: + """Key scheme kept identical to 1.x so existing ids keep resolving.""" url = getattr(item, "url", "") or "" data = getattr(item, "data", None) if url and url != "_": return url if isinstance(data, dict): - for k in ("id", "slug_url", "slug", "anime_id"): - if data.get(k) is not None: - return f"id:{data[k]}" + for key in ("id", "slug_url", "slug", "anime_id"): + if data.get(key) is not None: + return f"id:{data[key]}" return f"title:{getattr(item, 'title', 'unknown')}" -def _payload_from_item(item: Any) -> dict[str, Any]: +def _payload(item: Any) -> dict[str, Any]: data = getattr(item, "data", None) if isinstance(data, dict): return data raw = getattr(item, "raw_json", None) - if isinstance(raw, dict): - return {"raw_json": raw} - return {} - - -@lru_cache(maxsize=1) -def list_source_modules() -> list[str]: - import anicli_api.source as pkg - - path = Path(pkg.__path__[0]) - return sorted(p.stem for p in path.glob("*.py") if not p.name.startswith("_")) - - -def _http_clients(settings: Settings): - kwargs: dict[str, Any] = {} - if settings.http_proxy: - kwargs["proxy"] = settings.http_proxy - return HTTPSync(**kwargs) if kwargs else HTTPSync(), HTTPAsync(**kwargs) if kwargs else HTTPAsync() - - -def load_extractor(source_name: str, settings: Settings | None = None): - settings = settings or get_settings() - module_name = source_name.replace("-", "_") - module = importlib.import_module(f"anicli_api.source.{module_name}") - sync_http, async_http = _http_clients(settings) - return module.Extractor(http_client=sync_http, http_async_client=async_http), module - - -def _guess_year(anime: Any) -> int | None: - raw = getattr(anime, "raw_json", None) or {} - if isinstance(raw, dict): - for key in ("datePublished", "year", "aired_on"): - val = raw.get(key) - if isinstance(val, str): - m = re.search(r"(19|20)\d{2}", val) - if m: - return int(m.group(0)) - if isinstance(val, int) and 1900 < val < 2100: - return val - data = getattr(anime, "data", None) - if isinstance(data, dict): - for key in ("year", "season", "aired_on", "releaseDate"): - val = data.get(key) - if isinstance(val, int) and 1900 < val < 2100: - return val - if isinstance(val, str): - m = re.search(r"(19|20)\d{2}", val) - if m: - return int(m.group(0)) - if isinstance(val, dict) and "year" in val: - try: - return int(val["year"]) - except (TypeError, ValueError): - pass + return {"raw_json": raw} if isinstance(raw, dict) else {} + + +def _year(anime: Any) -> int | None: + def from_value(value: Any) -> int | None: + if isinstance(value, int) and 1900 < value < 2100: + return value + if isinstance(value, str): + match = re.search(r"(19|20)\d{2}", value) + return int(match.group(0)) if match else None + if isinstance(value, dict) and "year" in value: + return from_value(value["year"]) + return None + + for container, keys in ( + (getattr(anime, "raw_json", None), ("datePublished", "year", "aired_on")), + (getattr(anime, "data", None), ("year", "season", "aired_on", "releaseDate")), + ): + if isinstance(container, dict): + for key in keys: + found = from_value(container.get(key)) + if found: + return found return None -def _player_host(url: str) -> str | None: +def _ordinal(value: Any) -> int | None: try: - return urlparse(url).netloc or None - except Exception: + return int(float(str(value).strip())) + except (TypeError, ValueError): return None -def _pick_video(videos: list[Any], quality: int) -> Any: - """Pick closest quality, preferring HLS over DASH for browser compatibility.""" - closest = get_video_by_quality(videos, quality) - same_quality = [video for video in videos if int(video.quality) == int(closest.quality)] - format_priority = {"m3u8": 0, "mp4": 1, "webm": 2, "mpd": 3} - return min(same_quality, key=lambda video: format_priority.get(video.type, 99)) +def _short_error(exc: BaseException) -> str: + text = str(exc).strip() or type(exc).__name__ + return text.splitlines()[0][:300] -class SearchCache: - def __init__(self) -> None: - CACHE_DIR.mkdir(parents=True, exist_ok=True) - self._mem: dict[str, CachedSearch] = {} +class AnicliProvider: + """Adapter for one anicli-api source module, with its own HTTP session.""" - def _path(self, external_id: str) -> Path: - safe = re.sub(r"[^\w.-]+", "_", external_id)[:180] - return CACHE_DIR / f"{safe}.json" + def __init__(self, spec: SourceSpec, proxy: str | None) -> None: + self.spec = spec + self.clients = SourceClients(proxy) + self._module: Any = None - def put(self, external_id: str, entry: CachedSearch) -> None: - self._mem[external_id] = entry - try: - self._path(external_id).write_text( - json.dumps( - { - "source": entry.source, - "url": entry.url, - "title": entry.title, - "thumbnail": entry.thumbnail, - "payload": entry.payload, - "saved_at": entry.saved_at, - }, - ensure_ascii=False, - ), - encoding="utf-8", - ) - except Exception as exc: - logger.debug("Failed to persist search cache: %s", exc) - - def get(self, external_id: str) -> CachedSearch | None: - now = time.time() - entry = self._mem.get(external_id) - if entry and now - entry.saved_at <= CACHE_TTL: - return entry - path = self._path(external_id) - if not path.exists(): - return None - try: - raw = json.loads(path.read_text(encoding="utf-8")) - entry = CachedSearch( - source=raw["source"], - url=raw["url"], - title=raw["title"], - thumbnail=raw.get("thumbnail") or "", - payload=raw.get("payload") or {}, - saved_at=float(raw.get("saved_at") or 0), - ) - if now - entry.saved_at > CACHE_TTL: - return None - self._mem[external_id] = entry - return entry - except Exception: - return None + @property + def module(self) -> Any: + if self._module is None: + try: + self._module = importlib.import_module(f"anicli_api.source.{self.spec.module}") + except Exception as exc: # noqa: BLE001 - surface as provider failure + raise ProviderUnavailable(f"{self.spec.label} failed to load: {exc}", source=self.spec.id) from exc + return self._module + def extractor(self) -> Any: + return self.module.Extractor(http_client=self.clients.sync, http_async_client=self.clients.async_) -class ProviderRegistry: - """Hakuneko-inspired registry wrapping anicli-api extractors.""" + def build_search(self, entry: CachedSearch) -> Any: + search_cls = self.module.Search + kwargs: dict[str, Any] = { + "title": entry.title, + "thumbnail": entry.thumbnail, + "url": entry.url, + "http": self.clients.sync, + "http_async": self.clients.async_, + } + fields = {a.name for a in getattr(search_cls, "__attrs_attrs__", ())} + if "data" in fields: + if not entry.payload: + raise CacheExpired("Search data expired; search again", source=self.spec.id, stage="details") + kwargs["data"] = entry.payload + return search_cls(**kwargs) - def __init__(self, settings: Settings | None = None) -> None: + async def aclose(self) -> None: + await self.clients.aclose() + + +class ProviderRegistry: + def __init__(self, settings: Settings | None = None, *, health: HealthTracker | None = None) -> None: self.settings = settings or get_settings() - self.cache = SearchCache() - - def available_sources(self) -> list[str]: - modules = set(list_source_modules()) - ordered = [s for s in DEFAULT_SOURCES if s in modules] - ordered.extend(sorted(modules - set(ordered))) - return ordered - - async def search(self, query: str, sources: list[str] | None = None) -> list[SearchResult]: - sources = sources or list(DEFAULT_SOURCES) - batches = await asyncio.gather( - *[ - asyncio.wait_for( - self._search_one(source, query), - timeout=self.settings.provider_search_timeout, + self.cache = SearchCache(self.settings.data_path / "search_cache") + self.health = health or HealthTracker(self.settings.data_path / "provider_health.json") + self.enabled_sources: list[str] = default_enabled_sources() + self.default_source: str = DEFAULT_SOURCE + self._providers: dict[str, AnicliProvider] = {} + self._anime_cache: TTLCache[Any] = TTLCache(DETAIL_TTL) + self._episodes_cache: TTLCache[list[Any]] = TTLCache(DETAIL_TTL) + self._sources_cache: TTLCache[list[players.PlayerSource]] = TTLCache(SOURCES_TTL, max_items=512) + self._title_hints: dict[str, str] = {} + + # ------------------------------------------------------------------ setup + @property + def proxy(self) -> str | None: + return self.settings.http_proxy or None + + def provider(self, source: str) -> AnicliProvider: + spec = SOURCE_BY_ID.get(source) + if spec is None: + raise ProviderUnavailable(f"Unknown source '{source}'", source=source) + provider = self._providers.get(source) + if provider is None: + provider = AnicliProvider(spec, self.proxy) + self._providers[source] = provider + return provider + + async def apply_network_settings(self) -> None: + """Recreate sessions after the proxy changed (cached objects hold old clients).""" + old, self._providers = self._providers, {} + self._anime_cache.clear() + self._episodes_cache.clear() + self._sources_cache.clear() + for provider in old.values(): + await provider.aclose() + + def configure(self, *, enabled_sources: list[str] | None = None, default_source: str | None = None) -> None: + if enabled_sources is not None: + self.enabled_sources = [s for s in enabled_sources if s in SOURCE_BY_ID] or [DEFAULT_SOURCE] + if default_source and default_source in SOURCE_BY_ID: + self.default_source = default_source + + def ordered_sources(self) -> list[SourceSpec]: + enabled = set(self.enabled_sources) + return sorted(SOURCES, key=lambda s: (s.id != self.default_source, s.id not in enabled, not s.stable)) + + def remember_title(self, external_id: str, title: str | None) -> None: + if title: + self._title_hints[external_id] = title + + async def aclose(self) -> None: + for provider in self._providers.values(): + await provider.aclose() + self._providers = {} + + # ----------------------------------------------------------------- stages + async def _stage(self, source: str, stage: str, coro, timeout: float): + started = time.monotonic() + try: + result = await asyncio.wait_for(coro, timeout=timeout) + except TimeoutError as exc: + self.health.record(source, stage, False, int(timeout * 1000), f"timed out after {timeout:.0f}s") + raise ProviderTimeout(f"{stage} timed out after {timeout:.0f}s", source=source, stage=stage) from exc + except NotFound as exc: + # A title/episode that does not exist is not a provider fault. + exc.source, exc.stage = exc.source or source, exc.stage or stage + raise + except ProviderError as exc: + self.health.record(source, stage, False, int((time.monotonic() - started) * 1000), exc.message) + exc.source, exc.stage = exc.source or source, exc.stage or stage + raise + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - extractor internals vary wildly + self.health.record(source, stage, False, int((time.monotonic() - started) * 1000), _short_error(exc)) + logger.warning("%s %s failed: %s", source, stage, _short_error(exc)) + raise ProviderError(f"{stage} failed: {_short_error(exc)}", source=source, stage=stage) from exc + self.health.record(source, stage, True, int((time.monotonic() - started) * 1000)) + return result + + # ----------------------------------------------------------------- search + async def search(self, query: str, sources: list[str] | None = None) -> SearchResponse: + query = query.strip() + sources = [s for s in (sources or self.enabled_sources) if s in SOURCE_BY_ID] + if not sources: + raise ProviderUnavailable("No enabled sources") + statuses: list[ProviderSearchStatus] = [] + + async def run(source: str) -> list[SearchResult]: + started = time.monotonic() + label = SOURCE_BY_ID[source].label + try: + found = await self._search_one(source, query) + except ProviderError as exc: + statuses.append( + ProviderSearchStatus( + source=source, + label=label, + ok=False, + latency_ms=int((time.monotonic() - started) * 1000), + error=exc.message, + timed_out=isinstance(exc, ProviderTimeout), + ) ) - for source in sources - ], - return_exceptions=True, - ) + return [] + statuses.append( + ProviderSearchStatus( + source=source, + label=label, + ok=True, + count=len(found), + latency_ms=int((time.monotonic() - started) * 1000), + ) + ) + return found + + batches = await asyncio.gather(*(run(source) for source in sources)) results: list[SearchResult] = [] seen: set[str] = set() - for source, batch in zip(sources, batches, strict=True): - if isinstance(batch, TimeoutError): - logger.warning( - "Search timed out for %s after %.1fs", - source, - self.settings.provider_search_timeout, - ) - continue - if isinstance(batch, Exception): - logger.warning("Search failed for %s: %s", source, batch) - continue + for batch in batches: for item in batch: - key = f"{item.source}:{item.title.lower()}" - if key in seen: + if item.id in seen: continue - seen.add(key) + seen.add(item.id) results.append(item) - return results + order = {source: i for i, source in enumerate(sources)} + statuses.sort(key=lambda s: order.get(s.source, 99)) + return SearchResponse(query=query, results=results, providers=statuses) async def _search_one(self, source: str, query: str) -> list[SearchResult]: - extractor, _module = load_extractor(source, self.settings) - found = await extractor.a_search(query) + provider = self.provider(source) + + async def call() -> list[Any]: + return list(await provider.extractor().a_search(query)) + + found = await self._stage(source, "search", call(), self.settings.provider_search_timeout) out: list[SearchResult] = [] for item in found: - key = _stable_key(source, item) - external_id = encode_external_id(source, key) + title = (getattr(item, "title", "") or "").strip() + if not title: + continue + external_id = encode_external_id(source, _stable_key(item)) self.cache.put( external_id, CachedSearch( source=source, - url=item.url, - title=item.title, - thumbnail=item.thumbnail or "", - payload=_payload_from_item(item), + url=getattr(item, "url", "") or "", + title=title, + thumbnail=getattr(item, "thumbnail", "") or "", + payload=_payload(item), saved_at=time.time(), ), ) + self.remember_title(external_id, title) out.append( SearchResult( id=external_id, - title=item.title, - poster=item.thumbnail or None, - description=None, - year=None, + title=title, + poster=getattr(item, "thumbnail", None) or None, source=source, + source_label=provider.spec.label, ) ) return out - def _build_search_obj(self, entry: CachedSearch) -> Any: - extractor, module = load_extractor(entry.source, self.settings) - kwargs = {"http": extractor.http, "http_async": extractor.http_async} - search_cls = module.Search - fields = getattr(search_cls, "__attrs_attrs__", None) - init_kwargs: dict[str, Any] = { - "title": entry.title, - "thumbnail": entry.thumbnail, - "url": entry.url, - **kwargs, - } - if fields: - names = {a.name for a in fields} - if "data" in names and entry.payload: - init_kwargs["data"] = entry.payload - return search_cls(**init_kwargs) + # ------------------------------------------------------------ resolution + async def _research(self, external_id: str, title: str) -> Any: + """Search again by title and accept only the exact same provider key (never a lookalike).""" + source, key = decode_external_id(external_id) + provider = self.provider(source) + hits = await asyncio.wait_for(provider.extractor().a_search(title), self.settings.provider_search_timeout) + for hit in hits: + if _stable_key(hit) == key: + self.cache.put( + external_id, + CachedSearch(source, getattr(hit, "url", "") or "", hit.title, hit.thumbnail or "", _payload(hit), time.time()), + ) + return hit + raise CacheExpired("Title not found by provider any more; search again", source=source, stage="details") - async def _resolve_search(self, external_id: str) -> Any: + async def _search_object(self, external_id: str) -> Any: + source, key = decode_external_id(external_id) + provider = self.provider(source) entry = self.cache.get(external_id) - if entry is None: - source, key = decode_external_id(external_id) - # Best-effort rebuild for URL-based sources (AnimeGo etc.) - if key.startswith("title:"): - raise LookupError("Search cache expired; search again") - url = key if not key.startswith("id:") else "_" - entry = CachedSearch( - source=source, - url=url, - title=key.split("/")[-1].replace("-", " "), - thumbnail="", - payload={"id": int(key.split(":", 1)[1])} if key.startswith("id:") and key.split(":")[1].isdigit() else {}, - saved_at=time.time(), - ) - if entry.url == "_" and not entry.payload: - raise LookupError("Search cache expired; search again") - try: - return self._build_search_obj(entry) - except Exception as exc: - logger.warning("Rebuild search failed (%s), trying re-search: %s", external_id, exc) - extractor, _ = load_extractor(entry.source, self.settings) - hits = await extractor.a_search(entry.title) - for hit in hits: - if _stable_key(entry.source, hit) == decode_external_id(external_id)[1] or hit.url == entry.url: - return hit - if hits: - return hits[0] - raise LookupError(f"Anime not found for id={external_id}") from exc - - async def get_anime(self, external_id: str, include_voiceovers: bool = False) -> AnimeDetail: - search_obj = await self._resolve_search(external_id) - anime = await search_obj.a_get_anime() - episodes_raw = await anime.a_get_episodes() - episodes: list[EpisodeInfo] = [] - for ep in episodes_raw: - voiceovers: list[VoiceoverOption] = [] - if include_voiceovers: - try: - sources_list = await ep.a_get_sources() - voiceovers = [ - VoiceoverOption( - title=src.title, - url=src.url, - player_host=_player_host(src.url), - index=i, - ) - for i, src in enumerate(sources_list) - ] - except Exception as exc: - logger.warning("Failed to load sources for ep %s: %s", ep.ordinal, exc) - episodes.append( - EpisodeInfo( - title=ep.title, - ordinal=int(ep.ordinal), - season=1, - voiceovers=voiceovers, - ) - ) + if entry is None and (key.startswith("/") or key.startswith("http")): + # URL keys (AnimeGO, Yummy) can be rebuilt without cached data. + slug = key.rstrip("/").split("/")[-1] + entry = CachedSearch(source, key, self._title_hints.get(external_id) or slug.replace("-", " "), "", {}, time.time()) + if entry is not None: + try: + return provider.build_search(entry) + except CacheExpired: + pass + except ProviderUnavailable: + raise + except Exception as exc: # noqa: BLE001 + logger.info("Rebuilding search object for %s failed: %s", external_id, exc) + title = (entry.title if entry else None) or self._title_hints.get(external_id) + if not title: + raise CacheExpired("Search data expired; search for the title again", source=source, stage="details") + return await self._research(external_id, title) + + async def _anime(self, external_id: str) -> Any: + cached = self._anime_cache.get(external_id) + if cached is not None: + return cached + source, _ = decode_external_id(external_id) + + async def load() -> Any: + search_obj = await self._search_object(external_id) + try: + return await search_obj.a_get_anime() + except Exception as first: # noqa: BLE001 - stale payload -> one exact re-search + title = self._title_hints.get(external_id) or getattr(search_obj, "title", None) + if not title: + raise first + hit = await self._research(external_id, title) + return await hit.a_get_anime() + + anime = await self._stage(source, "details", load(), self.settings.provider_detail_timeout) + self._anime_cache.set(external_id, anime) + return anime + + async def _episodes(self, external_id: str) -> list[Any]: + cached = self._episodes_cache.get(external_id) + if cached is not None: + return cached + source, _ = decode_external_id(external_id) + anime = await self._anime(external_id) + async def load() -> list[Any]: + return list(await anime.a_get_episodes()) + + episodes = await self._stage(source, "episodes", load(), self.settings.provider_detail_timeout) + self._episodes_cache.set(external_id, episodes) + return episodes + + async def get_anime(self, external_id: str) -> AnimeDetail: source, _ = decode_external_id(external_id) + spec = self.provider(source).spec + anime = await self._anime(external_id) + episodes_raw = await self._episodes(external_id) + episodes: list[EpisodeInfo] = [] + seen: set[int] = set() + for ep in episodes_raw: + ordinal = _ordinal(getattr(ep, "ordinal", None)) + if ordinal is None or ordinal in seen: + continue + seen.add(ordinal) + episodes.append(EpisodeInfo(title=(getattr(ep, "title", "") or f"Episode {ordinal}").strip(), ordinal=ordinal)) + episodes.sort(key=lambda e: e.ordinal) + title = (getattr(anime, "title", "") or self._title_hints.get(external_id) or "Untitled").strip() + self.remember_title(external_id, title) return AnimeDetail( id=external_id, - title=anime.title, - poster=anime.thumbnail or None, - description=anime.description or None, - year=_guess_year(anime), + title=title, + poster=getattr(anime, "thumbnail", None) or None, + description=(getattr(anime, "description", "") or "").strip() or None, + year=_year(anime), source=source, + source_label=spec.label, seasons=[1], episodes=episodes, ) + async def _episode(self, external_id: str, episode: int) -> Any: + for ep in await self._episodes(external_id): + if _ordinal(getattr(ep, "ordinal", None)) == episode: + return ep + source, _ = decode_external_id(external_id) + raise NotFound(f"Episode {episode} not found", source=source, stage="episodes") + + async def _player_sources(self, external_id: str, episode: int, refresh: bool = False) -> list[players.PlayerSource]: + key = (external_id, episode) + if not refresh: + cached = self._sources_cache.get(key) + if cached is not None: + return cached + source, _ = decode_external_id(external_id) + ep = await self._episode(external_id, episode) + + async def load() -> list[Any]: + return list(await ep.a_get_sources()) + + raw = await self._stage(source, "voiceovers", load(), self.settings.provider_detail_timeout) + result = [ + players.PlayerSource( + voiceover=players.normalize_voiceover(getattr(src, "title", "")), + url=getattr(src, "url", "") or "", + label=players.player_label(getattr(src, "url", "") or ""), + raw=src, + ) + for src in raw + ] + self._sources_cache.set(key, result) + return result + + @staticmethod + def group_voiceovers(sources: list[players.PlayerSource]) -> "OrderedDict[str, VoiceoverGroup]": + groups: OrderedDict[str, VoiceoverGroup] = OrderedDict() + for src in sources: + key = players.voiceover_key(src.voiceover) + group = groups.setdefault(key, VoiceoverGroup(title=src.voiceover, players=[])) + group.players.append(src) + return groups + async def get_episode_voiceovers(self, external_id: str, episode: int) -> list[VoiceoverOption]: - search_obj = await self._resolve_search(external_id) - anime = await search_obj.a_get_anime() - episodes = await anime.a_get_episodes() - ep = next((e for e in episodes if int(e.ordinal) == episode), None) - if ep is None: - raise LookupError(f"Episode {episode} not found") - sources_list = await ep.a_get_sources() + groups = self.group_voiceovers(await self._player_sources(external_id, episode)) return [ VoiceoverOption( - title=src.title, - url=src.url, - player_host=_player_host(src.url), + title=group.title, + kind=players.voiceover_kind(group.title), # type: ignore[arg-type] + players=list(dict.fromkeys(p.label for p in group.players)), index=i, ) - for i, src in enumerate(sources_list) + for i, group in enumerate(groups.values()) ] - def pick_voiceover( - self, - options: list[VoiceoverOption], - preferred: str | None = None, - preferred_list: list[str] | None = None, - ) -> VoiceoverOption: - if not options: - raise LookupError("No voiceovers available") + def _ordered_groups(self, groups: "OrderedDict[str, VoiceoverGroup]", preferred: str | None) -> list[VoiceoverGroup]: + all_groups = list(groups.values()) if preferred: - for opt in options: - if preferred.lower() in opt.title.lower(): - return opt - prefs = preferred_list or self.settings.preferred_voiceover_list - for pref in prefs: - for opt in options: - if pref.lower() in opt.title.lower(): - return opt - return options[0] + wanted = players.voiceover_key(preferred) + exact = [g for k, g in groups.items() if k == wanted] + if exact: + return exact + partial = [g for k, g in groups.items() if wanted and (wanted in k or k in wanted)] + if partial: + return partial[:1] + return [] + ranked: list[VoiceoverGroup] = [] + for pref in self.settings.preferred_voiceover_list: + pref_key = players.voiceover_key(pref) + for key, group in groups.items(): + if pref_key and pref_key in key and group not in ranked: + ranked.append(group) + ranked.extend(g for g in all_groups if g not in ranked) + return ranked async def resolve_stream( self, @@ -413,38 +518,140 @@ async def resolve_stream( episode: int, voiceover: str | None = None, quality: int | None = None, + *, + player: str | None = None, + purpose: str = "play", + refresh: bool = False, ) -> ResolvedStream: - search_obj = await self._resolve_search(external_id) - anime_obj = await search_obj.a_get_anime() - episodes = await anime_obj.a_get_episodes() - ep = next((e for e in episodes if int(e.ordinal) == episode), None) - if ep is None: - raise LookupError(f"Episode {episode} not found") - sources_list = await ep.a_get_sources() - options = [ - VoiceoverOption(title=s.title, url=s.url, player_host=_player_host(s.url), index=i) - for i, s in enumerate(sources_list) - ] - chosen = self.pick_voiceover(options, preferred=voiceover) - src_obj = sources_list[chosen.index] - videos = await src_obj.a_get_videos() - if not videos: - raise LookupError("No video streams found") - q = quality or self.settings.default_quality - best = _pick_video(videos, q) - return ResolvedStream( - anime_title=anime_obj.title, - episode=episode, - voiceover=chosen.title, - source_url=chosen.url, - video=VideoStream( - type=best.type, - quality=int(best.quality), - url=best.url, - headers=dict(best.headers or {}), - ), + source, _ = decode_external_id(external_id) + allowed = players.BROWSER_TYPES if purpose == "play" else players.DOWNLOAD_TYPES + wanted_quality = quality or self.settings.default_quality + anime = await self._anime(external_id) + attempts: list[dict] = [] + + for attempt in range(2): # second pass refreshes stale player lists once + sources = await self._player_sources(external_id, episode, refresh=refresh or attempt > 0) + groups = self._ordered_groups(self.group_voiceovers(sources), voiceover) + if not groups: + raise NotFound(f"Voiceover '{voiceover}' is not available for episode {episode}", source=source, stage="voiceovers") + for group in groups[:MAX_AUTO_VOICEOVER_GROUPS]: + ranked = sorted( + group.players, + key=lambda p: (p.label != player if player else False, players.player_health.rank(p.label)), + ) + for candidate in ranked: + started = time.monotonic() + try: + videos = await asyncio.wait_for( + players.extract_videos(candidate.raw, self.proxy), self.settings.provider_stream_timeout + ) + chosen = players.pick_video(videos, wanted_quality, allowed) + if chosen is None: + kinds = sorted({getattr(v, "type", "?") for v in videos}) + raise LookupError( + f"no {'browser-playable ' if purpose == 'play' else ''}streams (got {', '.join(kinds) or 'none'})" + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - try the next player + players.player_health.failure(candidate.label) + message = "timed out" if isinstance(exc, TimeoutError) else _short_error(exc) + attempts.append({"voiceover": group.title, "player": candidate.label, "error": message}) + logger.info("%s ep%s %s/%s failed: %s", source, episode, group.title, candidate.label, message) + continue + players.player_health.success(candidate.label) + self.health.record(source, "stream", True, int((time.monotonic() - started) * 1000)) + headers = {str(k): str(v) for k, v in (getattr(chosen, "headers", None) or {}).items()} + if not any(k.lower() == "user-agent" for k in headers): + headers["User-Agent"] = DEFAULT_USER_AGENT + return ResolvedStream( + anime_title=getattr(anime, "title", "") or "", + episode=episode, + voiceover=group.title, + player=candidate.label, + video=ResolvedVideo(type=chosen.type, quality=int(chosen.quality), url=chosen.url, headers=headers), + qualities=players.available_qualities(videos, allowed), + browser_playable=chosen.type in players.BROWSER_TYPES, + source=source, + ) + if refresh: + break + + summary = "; ".join(f"{a['voiceover']}/{a['player']}: {a['error']}" for a in attempts[-6:]) + self.health.record(source, "stream", False, None, summary or "no players") + raise StreamUnavailable( + f"No working stream for episode {episode}" + (f" ({summary})" if summary else ""), + source=source, + stage="stream", + attempts=attempts, ) + def pick_voiceover_title(self, options: list[VoiceoverOption], preferred: str | None = None) -> str | None: + if not options: + return None + groups = OrderedDict((players.voiceover_key(o.title), VoiceoverGroup(o.title, [])) for o in options) + ordered = self._ordered_groups(groups, preferred) + return ordered[0].title if ordered else None + + # ------------------------------------------------------------ diagnostics + async def check(self, source: str) -> None: + """Run the full pipeline against a known title. Results land in ``self.health``.""" + spec = SOURCE_BY_ID.get(source) + if spec is None: + raise ProviderUnavailable(f"Unknown source '{source}'", source=source) + from backend.providers.errors import STAGES + + for stage in STAGES: # stale results must not survive a new check + self.health.get(source).stages.pop(stage, None) + try: + found = await self._search_one(source, spec.check_query) + if not found: + # Search works but the reference title is missing from this catalog: inconclusive, not a failure. + latency = self.health.get(source).stages.get("search") + self.health.record( + source, + "search", + True, + latency.latency_ms if latency else None, + f"проверочный тайтл «{spec.check_query}» не найден — остальные этапы не проверены", + ) + return + external_id = found[0].id + self._anime_cache.pop(external_id) + self._episodes_cache.pop(external_id) + detail = await self.get_anime(external_id) + if not detail.episodes: + self.health.record(source, "voiceovers", False, None, "title has no episodes") + return + first = detail.episodes[0].ordinal + await self._player_sources(external_id, first, refresh=True) + stream = await self.resolve_stream(external_id, first, refresh=True) + await self._probe_media(source, stream) + except ProviderError: + pass # already recorded per stage + finally: + self.health.mark_checked(source) + + async def _probe_media(self, source: str, stream: ResolvedStream) -> None: + """Fetch the first bytes of the resolved stream through the shared media client.""" + from backend.providers.http import media_clients + + client = media_clients.get(self.proxy) + started = time.monotonic() + try: + response = await client.get( + stream.video.url, + headers={**stream.video.headers, "Range": "bytes=0-2047"}, + follow_redirects=True, + timeout=15, + ) + if response.status_code >= 400: + raise ProviderError(f"media host answered HTTP {response.status_code} ({urlsplit(stream.video.url).netloc})") + except ProviderError as exc: + self.health.record(source, "stream", False, int((time.monotonic() - started) * 1000), exc.message) + except Exception as exc: # noqa: BLE001 + self.health.record(source, "stream", False, int((time.monotonic() - started) * 1000), _short_error(exc)) + _registry: ProviderRegistry | None = None diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py index 08a1c37..1060580 100644 --- a/backend/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -1,7 +1,9 @@ from datetime import datetime -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator + +QUALITIES = (360, 480, 720, 1080) class SearchResult(BaseModel): @@ -11,19 +13,29 @@ class SearchResult(BaseModel): description: str | None = None year: int | None = None source: str + source_label: str | None = None -class VideoStream(BaseModel): - type: str - quality: int - url: str - headers: dict[str, str] = Field(default_factory=dict) +class ProviderSearchStatus(BaseModel): + source: str + label: str + ok: bool + count: int = 0 + latency_ms: int | None = None + error: str | None = None + timed_out: bool = False + + +class SearchResponse(BaseModel): + query: str + results: list[SearchResult] + providers: list[ProviderSearchStatus] class VoiceoverOption(BaseModel): title: str - url: str - player_host: str | None = None + kind: Literal["dub", "subtitles", "original"] = "dub" + players: list[str] = Field(default_factory=list) index: int = 0 @@ -31,7 +43,6 @@ class EpisodeInfo(BaseModel): title: str ordinal: int season: int = 1 - voiceovers: list[VoiceoverOption] = Field(default_factory=list) class AnimeDetail(BaseModel): @@ -41,20 +52,49 @@ class AnimeDetail(BaseModel): description: str | None = None year: int | None = None source: str + source_label: str | None = None seasons: list[int] = Field(default_factory=lambda: [1]) episodes: list[EpisodeInfo] = Field(default_factory=list) +class StreamResolveRequest(BaseModel): + anime_id: str = Field(min_length=3, max_length=1024) + episode: int = Field(ge=0, le=100000) + season: int = Field(default=1, ge=0, le=1000) + voiceover: str | None = Field(default=None, max_length=256) + quality: int | None = Field(default=None, ge=144, le=4320) + player: str | None = Field(default=None, max_length=128) + refresh: bool = False + + +class StreamResolveResponse(BaseModel): + anime_title: str + episode: int + voiceover: str + player: str + type: str + quality: int + qualities: list[int] = Field(default_factory=list) + url: str + browser_playable: bool = True + expires_at: float + + class DownloadRequest(BaseModel): - anime_id: str - season: int = 1 - episode: int | None = None - episodes: list[int] | None = None - voiceover: str | None = None - steam_deck: bool = False + anime_id: str = Field(min_length=3, max_length=1024) + season: int = Field(default=1, ge=0, le=1000) + episode: int | None = Field(default=None, ge=0, le=100000) + episodes: list[int] | None = Field(default=None, max_length=2000) + voiceover: str | None = Field(default=None, max_length=256) + quality: int | None = Field(default=None, ge=144, le=4320) + steam_deck: bool | None = None + # Re-download episodes that are already in the library (e.g. another voiceover). + replace: bool = False class DownloadJobOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int anime_external_id: str anime_title: str @@ -66,12 +106,29 @@ class DownloadJobOut(BaseModel): status: str progress: float error: str | None = None + warning: str | None = None file_path: str | None = None steam_deck: bool = False + quality: int | None = None + player_host: str | None = None + bytes_done: int = 0 + bytes_total: int | None = None + speed_bps: float | None = None + attempts: int = 0 + resumable: bool = False created_at: datetime | None = None updated_at: datetime | None = None + finished_at: datetime | None = None + + +class SkippedDownload(BaseModel): + episode: int + reason: Literal["already_downloaded", "already_queued"] + - model_config = {"from_attributes": True} +class DownloadCreateResponse(BaseModel): + jobs: list[DownloadJobOut] + skipped: list[SkippedDownload] = Field(default_factory=list) class LibraryAnimeOut(BaseModel): @@ -82,6 +139,10 @@ class LibraryAnimeOut(BaseModel): year: int | None = None source: str episode_count: int = 0 + missing_count: int = 0 + watched_count: int = 0 + size_bytes: int = 0 + last_downloaded_at: datetime | None = None folder_path: str | None = None @@ -94,38 +155,84 @@ class LibraryEpisodeOut(BaseModel): voiceover: str | None = None file_path: str file_size: int | None = None + quality: int | None = None progress_seconds: float = 0 duration_seconds: float | None = None completed: bool = False downloaded_at: datetime | None = None + available: bool = True + stream_url: str | None = None + + +class LibraryStats(BaseModel): + titles: int + episodes: int + size_bytes: int + library_path: str + disk_total: int + disk_free: int + - model_config = {"from_attributes": True} +class RescanResult(BaseModel): + removed_missing: int + imported: int + removed_empty_dirs: int class ProgressUpdate(BaseModel): - anime_id: str | None = None - anime_title: str | None = None - poster: str | None = None - season: int = 1 - episode: int - progress_seconds: float - duration_seconds: float | None = None - local_episode_id: int | None = None + anime_id: str | None = Field(default=None, max_length=1024) + anime_title: str | None = Field(default=None, max_length=512) + poster: str | None = Field(default=None, max_length=2048) + season: int = Field(default=1, ge=0, le=1000) + episode: int = Field(ge=0, le=100000) + voiceover: str | None = Field(default=None, max_length=256) + progress_seconds: float = Field(ge=0, le=7 * 24 * 3600) + duration_seconds: float | None = Field(default=None, ge=0, le=7 * 24 * 3600) + local_episode_id: int | None = Field(default=None, ge=1) class WatchHistoryOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int anime_external_id: str anime_title: str poster: str | None = None season: int episode: int + voiceover: str | None = None progress_seconds: float duration_seconds: float | None = None local_episode_id: int | None = None updated_at: datetime | None = None + completed: bool = False + + +class HomeOut(BaseModel): + continue_watching: list[WatchHistoryOut] + recently_watched: list[WatchHistoryOut] + recently_downloaded: list[LibraryAnimeOut] + - model_config = {"from_attributes": True} +def dedupe_casefold(values: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for value in values: + if value.casefold() not in seen: + seen.add(value.casefold()) + out.append(value) + return out + + +def _validate_proxy(value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + if not value: + return "" + if not value.lower().startswith(("http://", "https://", "socks5://", "socks5h://")): + raise ValueError("Proxy must start with http://, https://, socks5:// or socks5h://") + return value class AppSettingsOut(BaseModel): @@ -135,37 +242,64 @@ class AppSettingsOut(BaseModel): steam_deck_crf: int = 23 steam_deck_height: int = 720 default_source: str = "animego" + enabled_sources: list[str] = Field(default_factory=list) http_proxy: str | None = None + max_concurrent_downloads: int = 2 + autoplay_next: bool = True + remember_volume: bool = True class AppSettingsUpdate(BaseModel): - preferred_voiceovers: list[str] | None = None + preferred_voiceovers: list[str] | None = Field(default=None, max_length=50) default_quality: int | None = None steam_deck_optimize: bool | None = None - steam_deck_crf: int | None = None - steam_deck_height: int | None = None - default_source: str | None = None - http_proxy: str | None = None - - -class StreamResolveRequest(BaseModel): - anime_id: str - episode: int - voiceover: str | None = None - quality: int | None = None - - -class StreamResolveResponse(BaseModel): - anime_title: str - episode: int - voiceover: str - video: VideoStream - proxy_url: str | None = None + steam_deck_crf: int | None = Field(default=None, ge=0, le=51) + steam_deck_height: int | None = Field(default=None, ge=240, le=2160) + default_source: str | None = Field(default=None, max_length=64) + enabled_sources: list[str] | None = Field(default=None, max_length=50) + http_proxy: str | None = Field(default=None, max_length=1024) + max_concurrent_downloads: int | None = Field(default=None, ge=1, le=6) + autoplay_next: bool | None = None + remember_volume: bool | None = None + + @field_validator("default_quality") + @classmethod + def _quality(cls, value: int | None) -> int | None: + if value is not None and value not in QUALITIES: + raise ValueError(f"Quality must be one of {QUALITIES}") + return value + + @field_validator("http_proxy") + @classmethod + def _proxy(cls, value: str | None) -> str | None: + return _validate_proxy(value) + + @field_validator("preferred_voiceovers") + @classmethod + def _voiceovers(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None + cleaned = [v.strip()[:128] for v in value if v and v.strip()] + return dedupe_casefold(cleaned) + + +class StageOut(BaseModel): + ok: bool + at: float + latency_ms: int | None = None + error: str | None = None -class SourceInfo(BaseModel): +class ProviderInfo(BaseModel): id: str label: str + stable: bool + enabled: bool + is_default: bool + note: str = "" + status: Literal["ok", "degraded", "down", "partial", "unknown"] + stages: dict[str, StageOut] = Field(default_factory=dict) + last_check_at: float | None = None class ApiMessage(BaseModel): diff --git a/backend/services/ffmpeg_tools.py b/backend/services/ffmpeg_tools.py index 6207de3..35009ab 100644 --- a/backend/services/ffmpeg_tools.py +++ b/backend/services/ffmpeg_tools.py @@ -1,21 +1,45 @@ -"""ffmpeg helpers — remux / Steam Deck optimize (Hakuneko mux inspiration).""" +"""ffmpeg helpers — stream download, validation, Steam Deck transcode (HakuNeko mux inspiration). + +Progress comes from ffmpeg's machine-readable ``-progress pipe:1`` output and the +input duration it reports; nothing is estimated. +""" from __future__ import annotations import asyncio import logging import os +import re import shutil import stat import sys +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass +from functools import lru_cache from pathlib import Path from backend.config import ROOT_DIR, get_settings logger = logging.getLogger("animebox.ffmpeg") -MACOS_PERMISSION_HINT = ( - " Run 'bash scripts/grant-macos-permissions.sh' from the extracted AnimeBox folder." -) +MACOS_PERMISSION_HINT = " Run 'bash scripts/grant-macos-permissions.sh' from the extracted AnimeBox folder." +_DURATION_RE = re.compile(r"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)") + + +@dataclass +class FfmpegProgress: + out_seconds: float + total_size: int + duration: float | None + + @property + def fraction(self) -> float | None: + if not self.duration or self.duration <= 0: + return None + return max(0.0, min(1.0, self.out_seconds / self.duration)) + + +ProgressCallback = Callable[[FfmpegProgress], None] def bundled_ffmpeg_path() -> Path: @@ -55,7 +79,30 @@ def _macos_permission_hint(executable: str | None = None) -> str: return MACOS_PERMISSION_HINT -async def run_ffmpeg(args: list[str]) -> None: +def parse_duration(text: str) -> float | None: + match = _DURATION_RE.search(text) + if not match: + return None + hours, minutes, seconds = match.groups() + return int(hours) * 3600 + int(minutes) * 60 + float(seconds) + + +async def _terminate(proc) -> None: + if proc.returncode is not None: + return + try: + proc.terminate() + except ProcessLookupError: + return + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except TimeoutError: + proc.kill() + await proc.wait() + + +async def run_ffmpeg(args: list[str], on_progress: ProgressCallback | None = None) -> str: + """Run ffmpeg; returns the stderr tail. The process is always reaped, also on cancellation.""" executable = ffmpeg_path() if not executable: raise RuntimeError(f"ffmpeg not found or not executable.{_macos_permission_hint()}") @@ -63,6 +110,7 @@ async def run_ffmpeg(args: list[str]) -> None: proc = await asyncio.create_subprocess_exec( executable, *args, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -71,86 +119,175 @@ async def run_ffmpeg(args: list[str]) -> None: raise RuntimeError( f"Could not start ffmpeg at {executable}: {detail}.{_macos_permission_hint(executable)}" ) from exc + + tail: deque[str] = deque(maxlen=60) + duration: list[float | None] = [None] + + async def read_stderr() -> None: + while True: + line = await proc.stderr.readline() + if not line: + return + text = line.decode("utf-8", errors="ignore").rstrip() + tail.append(text) + if duration[0] is None: + duration[0] = parse_duration(text) + + async def read_stdout() -> None: + values: dict[str, str] = {} + while True: + line = await proc.stdout.readline() + if not line: + return + key, _, value = line.decode("utf-8", errors="ignore").strip().partition("=") + values[key] = value + if key == "progress" and on_progress is not None: + try: + out_us = int(values.get("out_time_us") or values.get("out_time_ms") or 0) + except ValueError: + out_us = 0 + try: + size = int(values.get("total_size") or 0) + except ValueError: + size = 0 + on_progress(FfmpegProgress(out_seconds=max(out_us, 0) / 1_000_000, total_size=size, duration=duration[0])) + try: - _out, err = await proc.communicate() + await asyncio.gather(read_stderr(), read_stdout()) + await proc.wait() except asyncio.CancelledError: - if proc.returncode is None: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=5) - except TimeoutError: - proc.kill() - await proc.wait() + await _terminate(proc) + raise + except BaseException: + await _terminate(proc) raise if proc.returncode != 0: - detail = err.decode("utf-8", errors="ignore")[-1600:].strip() or "no error output" + detail = "\n".join(tail)[-1600:].strip() or "no error output" raise RuntimeError( - f"ffmpeg failed with exit code {proc.returncode} ({executable}): {detail}." - f"{_macos_permission_hint(executable)}" + f"ffmpeg failed with exit code {proc.returncode} ({executable}): {detail}.{_macos_permission_hint(executable)}" ) + return "\n".join(tail) -async def remux_to_mkv(src: Path, dest: Path) -> Path: - dest.parent.mkdir(parents=True, exist_ok=True) - await run_ffmpeg(["-y", "-i", str(src), "-c", "copy", "-movflags", "+faststart", str(dest)]) - return dest +@lru_cache(maxsize=1) +def _hls_demuxer_options() -> str: + executable = ffmpeg_path() + if not executable: + return "" + import subprocess + + try: + return subprocess.run( + [executable, "-hide_banner", "-h", "demuxer=hls"], capture_output=True, text=True, timeout=15 + ).stdout + except (OSError, subprocess.SubprocessError): + return "" -async def download_stream_with_ffmpeg(url: str, dest: Path, headers: dict[str, str] | None = None) -> Path: +def hls_input_options() -> list[str]: + """Allow extension-less segment names (common on CDNs and behind the local media proxy).""" + help_text = _hls_demuxer_options() + options: list[str] = [] + if "allowed_extensions" in help_text: + options += ["-allowed_extensions", "ALL"] + if "extension_picky" in help_text: + options += ["-extension_picky", "0"] + return options + + +def header_args(headers: dict[str, str] | None) -> list[str]: + if not headers: + return [] + args: list[str] = [] + rest = {k: v for k, v in headers.items() if k.lower() != "user-agent"} + user_agent = next((v for k, v in headers.items() if k.lower() == "user-agent"), None) + if user_agent: + args += ["-user_agent", user_agent] + if rest: + args += ["-headers", "".join(f"{k}: {v}\r\n" for k, v in rest.items())] + return args + + +async def download_stream_with_ffmpeg( + url: str, + dest: Path, + headers: dict[str, str] | None = None, + *, + on_progress: ProgressCallback | None = None, + hls: bool = False, +) -> Path: dest.parent.mkdir(parents=True, exist_ok=True) - header_args: list[str] = [] - if headers: - header_blob = "".join(f"{k}: {v}\r\n" for k, v in headers.items()) - header_args = ["-headers", header_blob] - await run_ffmpeg( - [ - *header_args, - "-y", - "-i", - url, - # Let ffmpeg choose the highest-resolution video stream from master - # playlists instead of forcing the first (usually lowest) variant. - "-c", - "copy", - "-movflags", - "+faststart", - str(dest), - ] - ) + args = [ + "-hide_banner", + "-nostats", + "-loglevel", + "info", + "-progress", + "pipe:1", + *header_args(headers), + *(hls_input_options() if hls else []), + "-y", + "-i", + url, + # ffmpeg picks the highest-resolution variant of a master playlist by default. + "-c", + "copy", + "-movflags", + "+faststart", + str(dest), + ] + await run_ffmpeg(args, on_progress=on_progress) return dest -async def download_hls_with_ffmpeg(url: str, dest: Path, headers: dict[str, str] | None = None) -> Path: - """Backward-compatible alias for stream downloads.""" - return await download_stream_with_ffmpeg(url, dest, headers) +async def probe_duration(path: Path) -> float | None: + """Container duration as reported by ffmpeg, or ``None`` if unreadable.""" + executable = ffmpeg_path() + if not executable: + return None + proc = await asyncio.create_subprocess_exec( + executable, + "-hide_banner", + "-i", + str(path), + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + _out, err = await asyncio.wait_for(proc.communicate(), timeout=60) + except (TimeoutError, asyncio.CancelledError): + await _terminate(proc) + raise + return parse_duration(err.decode("utf-8", errors="ignore")) -async def optimize_for_steam_deck(src: Path, dest: Path | None = None) -> Path: - """H.265 / smaller size / suitable resolution for Steam Deck travel library.""" +async def optimize_for_steam_deck(src: Path, dest: Path, on_progress: ProgressCallback | None = None) -> Path: + """H.265 / reduced resolution for a compact Steam Deck travel library.""" settings = get_settings() - dest = dest or src.with_name(src.stem + ".steamdeck.mkv") - height = settings.steam_deck_height - crf = settings.steam_deck_crf await run_ffmpeg( [ + "-hide_banner", + "-nostats", + "-progress", + "pipe:1", "-y", "-i", str(src), "-vf", - f"scale=-2:{height}", + f"scale=-2:'min({int(settings.steam_deck_height)},ih)'", "-c:v", "libx265", "-crf", - str(crf), + str(int(settings.steam_deck_crf)), "-preset", "medium", "-c:a", "aac", "-b:a", "128k", - "-movflags", - "+faststart", str(dest), - ] + ], + on_progress=on_progress, ) return dest diff --git a/backend/services/library.py b/backend/services/library.py index 0548565..d0a0585 100644 --- a/backend/services/library.py +++ b/backend/services/library.py @@ -1,73 +1,186 @@ -"""Local library filesystem helpers (Hakuneko-inspired path layout).""" +"""Library filesystem helpers (Hakuneko-inspired path layout). + +Layout:: + + library//Season /Episode . + library//poster.jpg + library//metadata.json + +Path helpers in this module are *pure*: computing where an episode would live +never touches the disk. Directories are created only by :func:`ensure_dir`, +which the download manager calls right before writing a file. (In 1.x merely +opening an anime page created its folder, which made browsed titles show up in +the offline library.) +""" from __future__ import annotations import json import logging +import os import re +import shutil +import tempfile from datetime import datetime, timezone from pathlib import Path -from backend.config import get_settings +from backend.config import ROOT_DIR, get_settings logger = logging.getLogger("animebox.library") +MEDIA_EXTENSIONS = frozenset({".mp4", ".mkv", ".webm", ".m4v", ".mov", ".ts"}) +# Anything smaller cannot be a playable episode (truncated / error page saved to disk). +MIN_MEDIA_BYTES = 1024 +PART_MARKER = ".part" +_EPISODE_FILE_RE = re.compile(r"^Episode (\d{1,4})(?:\.steamdeck)?\.(\w+)$", re.IGNORECASE) +_SEASON_DIR_RE = re.compile(r"^Season (\d{1,3})$", re.IGNORECASE) + + +def library_root() -> Path: + return get_settings().library_path + def sanitize_name(name: str) -> str: - name = name.strip() or "Unknown" + name = (name or "").strip() or "Unknown" name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name) name = re.sub(r"\s+", " ", name).strip(" .") return name[:120] or "Unknown" -def anime_dir(title: str) -> Path: - root = get_settings().library_path - path = root / sanitize_name(title) - path.mkdir(parents=True, exist_ok=True) - return path +def anime_dir_path(title: str, root: Path | None = None) -> Path: + return (root or library_root()) / sanitize_name(title) -def season_dir(title: str, season: int) -> Path: - path = anime_dir(title) / f"Season {season}" +def season_dir_path(title: str, season: int, root: Path | None = None) -> Path: + return anime_dir_path(title, root) / f"Season {int(season)}" + + +def episode_file_path(title: str, season: int, episode: int, ext: str = "mp4", root: Path | None = None) -> Path: + return season_dir_path(title, season, root) / f"Episode {int(episode):02d}.{ext.lstrip('.')}" + + +def partial_path(final: Path) -> Path: + """Temporary sibling of ``final``; keeps the media suffix last so ffmpeg picks the muxer.""" + return final.with_name(f"{final.stem}{PART_MARKER}{final.suffix}") + + +def ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path -def episode_path(title: str, season: int, episode: int, ext: str = "mkv") -> Path: - return season_dir(title, season) / f"Episode {episode:02d}.{ext.lstrip('.')}" +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False -def write_metadata(title: str, meta: dict) -> Path: - path = anime_dir(title) / "metadata.json" - existing: dict = {} - if path.exists(): - try: - existing = json.loads(path.read_text(encoding="utf-8")) - except Exception: - existing = {} - existing.update(meta) - existing["updated_at"] = datetime.now(timezone.utc).isoformat() - path.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8") +def to_stored_path(path: Path, root: Path | None = None) -> str: + """Store library files relative to the library root so moving a portable folder keeps working.""" + root = (root or library_root()).resolve() + resolved = path.resolve() + if _is_relative_to(resolved, root): + return resolved.relative_to(root).as_posix() + return str(resolved) + + +def resolve_stored_path(stored: str, root: Path | None = None) -> Path: + """Resolve a stored path, including 1.x values relative to the process working directory.""" + root = root or library_root() + normalized = (stored or "").replace("\\", "/") + candidate = Path(normalized) + if candidate.is_absolute() or re.match(r"^[A-Za-z]:/", normalized): + return Path(stored) + in_library = root / candidate + if in_library.exists(): + return in_library + legacy = ROOT_DIR / candidate # 1.x stored "library//..." relative to the app folder + if legacy.exists(): + return legacy + return in_library + + +def is_partial_file(path: Path) -> bool: + return PART_MARKER in path.name.lower() + + +def is_valid_media_file(path: Path) -> bool: + """Cheap validity check used by every library read (existence, type, size).""" + if path.suffix.lower() not in MEDIA_EXTENSIONS or is_partial_file(path): + return False + try: + stat = path.stat() + except OSError: + return False + return path.is_file() and stat.st_size >= MIN_MEDIA_BYTES + + +def file_size(path: Path) -> int | None: + try: + return path.stat().st_size + except OSError: + return None + + +def parse_episode_file(path: Path) -> tuple[int, int] | None: + """Return ``(season, episode)`` for files that follow the library layout.""" + match = _EPISODE_FILE_RE.match(path.name) + season_match = _SEASON_DIR_RE.match(path.parent.name) + if not match or not season_match: + return None + return int(season_match.group(1)), int(match.group(1)) + + +def _atomic_write_text(path: Path, text: str) -> None: + ensure_dir(path.parent) + fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def read_metadata(anime_dir: Path) -> dict: + path = anime_dir / "metadata.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def write_metadata(anime_dir: Path, meta: dict) -> Path: + path = anime_dir / "metadata.json" + data = read_metadata(anime_dir) + data.update(meta) + data["updated_at"] = datetime.now(timezone.utc).isoformat() + _atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2)) return path -def append_episode_metadata( - title: str, +def record_episode_metadata( + anime_dir: Path, *, + anime_id: str, + title: str, season: int, episode: int, voiceover: str | None, file_name: str, source: str, + quality: int | None = None, ) -> None: - path = anime_dir(title) / "metadata.json" - data: dict = {"title": title, "episodes": []} - if path.exists(): - try: - data = json.loads(path.read_text(encoding="utf-8")) - except Exception: - pass - episodes = [e for e in data.get("episodes", []) if not (e.get("season") == season and e.get("episode") == episode)] + data = read_metadata(anime_dir) + episodes = [ + item + for item in data.get("episodes", []) + if isinstance(item, dict) and not (item.get("season") == season and item.get("episode") == episode) + ] episodes.append( { "season": season, @@ -75,29 +188,122 @@ def append_episode_metadata( "voiceover": voiceover, "file": file_name, "source": source, + "quality": quality, "downloaded_at": datetime.now(timezone.utc).isoformat(), - "progress": 0, } ) - data["title"] = title - data["episodes"] = sorted(episodes, key=lambda e: (e.get("season", 1), e.get("episode", 0))) - write_metadata(title, data) + write_metadata( + anime_dir, + { + "id": anime_id, + "title": title, + "episodes": sorted(episodes, key=lambda item: (item.get("season", 1), item.get("episode", 0))), + }, + ) + + +def remove_episode_metadata(anime_dir: Path, season: int, episode: int) -> None: + data = read_metadata(anime_dir) + if not data: + return + data["episodes"] = [ + item + for item in data.get("episodes", []) + if isinstance(item, dict) and not (item.get("season") == season and item.get("episode") == episode) + ] + write_metadata(anime_dir, data) + +_IMAGE_SIGNATURES = ( + (bytes.fromhex("ffd8ff"), "image/jpeg", "jpg"), + (bytes.fromhex("89504e47"), "image/png", "png"), + (b"GIF8", "image/gif", "gif"), +) -async def save_poster(title: str, poster_url: str | None, headers: dict | None = None) -> Path | None: - if not poster_url: + +def image_type(data: bytes) -> tuple[str, str] | None: + """(mime, extension) from magic bytes; 1.x saved WebP posters under a .jpg name.""" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp", "webp" + for signature, mime, ext in _IMAGE_SIGNATURES: + if data.startswith(signature): + return mime, ext + return None + + +def poster_file(anime_dir: Path) -> Path | None: + for name in ("poster.jpg", "poster.png", "poster.webp"): + candidate = anime_dir / name + if candidate.is_file() and candidate.stat().st_size > 0: + return candidate + return None + + +async def save_poster(anime_dir: Path, poster_url: str | None, client) -> Path | None: + """Best-effort poster download. Failures never affect the downloaded episode.""" + if not poster_url or not poster_url.startswith(("http://", "https://")): return None - dest = anime_dir(title) / "poster.jpg" - if dest.exists(): - return dest + existing = poster_file(anime_dir) + if existing: + return existing try: - import httpx - - async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client: - resp = await client.get(poster_url, headers=headers or {}) - resp.raise_for_status() - dest.write_bytes(resp.content) - return dest - except Exception as exc: - logger.warning("Poster download failed: %s", exc) + response = await client.get(poster_url, timeout=30, follow_redirects=True) + response.raise_for_status() + detected = image_type(response.content[:16]) + if detected is None: + raise ValueError(f"not an image (content-type {response.headers.get('content-type', '')!r})") + dest = anime_dir / f"poster.{'jpg' if detected[1] == 'jpg' else detected[1]}" + ensure_dir(anime_dir) + tmp = dest.with_name(".poster.tmp") + tmp.write_bytes(response.content) + os.replace(tmp, dest) + return dest + except Exception as exc: # noqa: BLE001 - poster is optional + logger.warning("Poster download failed for %s: %s", anime_dir.name, exc) return None + + +def remove_empty_dirs(root: Path, *, keep_root: bool = True) -> int: + """Remove directories that contain no files at all. Never deletes files.""" + removed = 0 + if not root.is_dir(): + return 0 + for current, dirs, files in os.walk(root, topdown=False): + path = Path(current) + if keep_root and path == root: + continue + if not files and not any((path / d).exists() for d in dirs): + try: + path.rmdir() + removed += 1 + except OSError: + pass + return removed + + +def delete_files(paths: list[Path], anime_dir: Path | None = None) -> int: + deleted = 0 + for path in paths: + try: + path.unlink() + deleted += 1 + except FileNotFoundError: + continue + except OSError as exc: + logger.warning("Could not delete %s: %s", path, exc) + if anime_dir is not None and anime_dir.is_dir(): + # The title folder may now hold only artwork/metadata. + remaining_media = [p for p in anime_dir.rglob("*") if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS] + if not remaining_media: + for leftover in ("poster.jpg", "poster.png", "poster.webp", "metadata.json"): + (anime_dir / leftover).unlink(missing_ok=True) + remove_empty_dirs(anime_dir, keep_root=False) + return deleted + + +def disk_usage(path: Path) -> dict[str, int]: + try: + usage = shutil.disk_usage(path) + return {"total": usage.total, "used": usage.used, "free": usage.free} + except OSError: + return {"total": 0, "used": 0, "free": 0} diff --git a/backend/services/library_index.py b/backend/services/library_index.py new file mode 100644 index 0000000..7087aaf --- /dev/null +++ b/backend/services/library_index.py @@ -0,0 +1,323 @@ +"""Offline library queries. + +The library is derived from ``library_episodes`` rows whose files actually exist +and pass :func:`backend.services.library.is_valid_media_file`. Catalog metadata +(``anime_titles``) only decorates those entries; browsing a title never adds it. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from urllib.parse import quote + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models import AnimeTitle, LibraryEpisode, WatchHistory +from backend.providers.ids import decode_external_id, safe_canonical_id +from backend.schemas import LibraryAnimeOut, LibraryEpisodeOut, LibraryStats, RescanResult +from backend.services import library + +logger = logging.getLogger("animebox.library") + + +def poster_endpoint(anime_id: str) -> str: + return f"/api/library/poster?id={quote(anime_id, safe='')}" + + +def episode_stream_url(episode_id: int) -> str: + return f"/api/library/file/{episode_id}" + + +def anime_dir_for(title: AnimeTitle | None, episodes: list[LibraryEpisode]) -> Path | None: + if title is not None and title.folder_path: + return library.resolve_stored_path(title.folder_path) + for ep in episodes: + path = library.resolve_stored_path(ep.file_path) + if library.parse_episode_file(path): + return path.parent.parent + return None + + +async def _titles(db: AsyncSession, ids: list[str]) -> dict[str, AnimeTitle]: + if not ids: + return {} + rows = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id.in_(ids)))).scalars().all() + return {row.external_id: row for row in rows} + + +async def list_library(db: AsyncSession) -> list[LibraryAnimeOut]: + rows = ( + await db.execute(select(LibraryEpisode).order_by(LibraryEpisode.season, LibraryEpisode.episode)) + ).scalars().all() + groups: dict[str, list[LibraryEpisode]] = defaultdict(list) + for row in rows: + groups[row.anime_external_id].append(row) + titles = await _titles(db, list(groups)) + out: list[LibraryAnimeOut] = [] + for anime_id, episodes in groups.items(): + valid: list[tuple[LibraryEpisode, int]] = [] + for ep in episodes: + path = library.resolve_stored_path(ep.file_path) + if library.is_valid_media_file(path): + valid.append((ep, library.file_size(path) or 0)) + if not valid: + continue # nothing playable offline -> not part of the library + title = titles.get(anime_id) + anime_dir = anime_dir_for(title, [ep for ep, _ in valid]) + local_poster = library.poster_file(anime_dir) if anime_dir else None + try: + source = decode_external_id(anime_id)[0] + except ValueError: + source = "local" + last = max((ep.downloaded_at for ep, _ in valid if ep.downloaded_at), default=None) + out.append( + LibraryAnimeOut( + id=anime_id, + title=title.title if title else (anime_dir.name if anime_dir else anime_id), + poster=poster_endpoint(anime_id) if local_poster else (title.poster if title else None), + description=title.description if title else None, + year=title.year if title else None, + source=source, + episode_count=len(valid), + missing_count=len(episodes) - len(valid), + watched_count=sum(1 for ep, _ in valid if ep.completed), + size_bytes=sum(size for _, size in valid), + last_downloaded_at=last, + folder_path=str(anime_dir) if anime_dir else None, + ) + ) + out.sort(key=lambda item: item.last_downloaded_at or datetime.min, reverse=True) + return out + + +async def library_episodes(db: AsyncSession, anime_id: str) -> list[LibraryEpisodeOut]: + rows = ( + await db.execute( + select(LibraryEpisode) + .where(LibraryEpisode.anime_external_id == anime_id) + .order_by(LibraryEpisode.season, LibraryEpisode.episode) + ) + ).scalars().all() + out: list[LibraryEpisodeOut] = [] + for row in rows: + path = library.resolve_stored_path(row.file_path) + available = library.is_valid_media_file(path) + item = LibraryEpisodeOut.model_validate(row, from_attributes=True) + item.available = available + item.file_size = library.file_size(path) if available else row.file_size + item.stream_url = episode_stream_url(row.id) if available else None + out.append(item) + return out + + +async def downloaded_keys(db: AsyncSession, anime_id: str) -> set[tuple[int, int]]: + return {(ep.season, ep.episode) for ep in await library_episodes(db, anime_id) if ep.available} + + +async def upsert_title( + db: AsyncSession, + *, + anime_id: str, + source: str, + title: str, + poster: str | None = None, + description: str | None = None, + year: int | None = None, + folder: Path | None = None, +) -> AnimeTitle: + row = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() + if row is None: + row = AnimeTitle(external_id=anime_id, source=source, title=title) + db.add(row) + row.title = title or row.title + row.poster = poster or row.poster + row.description = description or row.description + row.year = year or row.year + if folder is not None: + row.folder_path = library.to_stored_path(folder) + return row + + +async def record_episode( + db: AsyncSession, + *, + anime_id: str, + season: int, + episode: int, + episode_title: str | None, + voiceover: str | None, + path: Path, + quality: int | None, +) -> tuple[LibraryEpisode, Path | None]: + """Upsert the library row. Returns the row and the previous file if it was replaced.""" + row = ( + await db.execute( + select(LibraryEpisode).where( + LibraryEpisode.anime_external_id == anime_id, + LibraryEpisode.season == season, + LibraryEpisode.episode == episode, + ) + ) + ).scalar_one_or_none() + stored = library.to_stored_path(path) + previous: Path | None = None + if row is None: + row = LibraryEpisode(anime_external_id=anime_id, season=season, episode=episode, file_path=stored) + db.add(row) + else: + old = library.resolve_stored_path(row.file_path) + if old.resolve() != path.resolve(): + previous = old + row.file_path = stored + row.downloaded_at = datetime.now().astimezone() + row.completed = False + row.progress_seconds = 0.0 + row.title = episode_title or row.title or f"Episode {episode}" + row.voiceover = voiceover + row.file_size = library.file_size(path) + row.quality = quality + await db.flush() + return row, previous + + +async def delete_episode(db: AsyncSession, episode_id: int) -> bool: + row = await db.get(LibraryEpisode, episode_id) + if row is None: + return False + path = library.resolve_stored_path(row.file_path) + anime_dir = path.parent.parent if library.parse_episode_file(path) else None + library.delete_files([path], anime_dir=None) + if anime_dir is not None: + library.remove_episode_metadata(anime_dir, row.season, row.episode) + library.remove_empty_dirs(anime_dir) + await db.execute( + WatchHistory.__table__.update().where(WatchHistory.local_episode_id == row.id).values(local_episode_id=None) + ) + await db.delete(row) + await _detach_empty_title(db, row.anime_external_id, anime_dir) + await db.commit() + return True + + +async def _detach_empty_title(db: AsyncSession, anime_id: str, anime_dir: Path | None) -> None: + await db.flush() + remaining = ( + await db.execute(select(LibraryEpisode.id).where(LibraryEpisode.anime_external_id == anime_id).limit(1)) + ).first() + if remaining is None: + if anime_dir is not None: + library.delete_files([], anime_dir=anime_dir) + title = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() + if title is not None: + title.folder_path = None + + +async def delete_anime(db: AsyncSession, anime_id: str) -> int: + rows = (await db.execute(select(LibraryEpisode).where(LibraryEpisode.anime_external_id == anime_id))).scalars().all() + if not rows: + return 0 + title = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == anime_id))).scalar_one_or_none() + anime_dir = anime_dir_for(title, list(rows)) + paths = [library.resolve_stored_path(r.file_path) for r in rows] + deleted = library.delete_files(paths, anime_dir=anime_dir) + ids = [r.id for r in rows] + await db.execute( + WatchHistory.__table__.update().where(WatchHistory.local_episode_id.in_(ids)).values(local_episode_id=None) + ) + await db.execute(delete(LibraryEpisode).where(LibraryEpisode.id.in_(ids))) + if title is not None: + title.folder_path = None + await db.commit() + return deleted + + +async def rescan(db: AsyncSession) -> RescanResult: + """Drop rows whose files vanished and import untracked episodes that carry an AnimeBox id.""" + rows = (await db.execute(select(LibraryEpisode))).scalars().all() + removed = 0 + tracked: set[Path] = set() + for row in rows: + path = library.resolve_stored_path(row.file_path) + if library.is_valid_media_file(path): + tracked.add(path.resolve()) + continue + await db.delete(row) + removed += 1 + await db.flush() + + imported = 0 + root = library.library_root() + if root.is_dir(): + for anime_dir in sorted(p for p in root.iterdir() if p.is_dir()): + meta = library.read_metadata(anime_dir) + raw_id = meta.get("id") + if not isinstance(raw_id, str): + continue + anime_id = safe_canonical_id(raw_id) + for media in sorted(anime_dir.rglob("*")): + if media.resolve() in tracked or not library.is_valid_media_file(media): + continue + key = library.parse_episode_file(media) + if key is None: + continue + exists = ( + await db.execute( + select(LibraryEpisode.id).where( + LibraryEpisode.anime_external_id == anime_id, + LibraryEpisode.season == key[0], + LibraryEpisode.episode == key[1], + ) + ) + ).first() + if exists: + continue + voice = next( + ( + e.get("voiceover") + for e in meta.get("episodes", []) + if isinstance(e, dict) and e.get("season") == key[0] and e.get("episode") == key[1] + ), + None, + ) + db.add( + LibraryEpisode( + anime_external_id=anime_id, + season=key[0], + episode=key[1], + title=f"Episode {key[1]}", + voiceover=voice, + file_path=library.to_stored_path(media), + file_size=library.file_size(media), + ) + ) + tracked.add(media.resolve()) + imported += 1 + if imported: + try: + source = decode_external_id(anime_id)[0] + except ValueError: + source = "local" + await upsert_title( + db, anime_id=anime_id, source=source, title=str(meta.get("title") or anime_dir.name), folder=anime_dir + ) + await db.commit() + removed_dirs = library.remove_empty_dirs(root) + return RescanResult(removed_missing=removed, imported=imported, removed_empty_dirs=removed_dirs) + + +async def stats(db: AsyncSession) -> LibraryStats: + items = await list_library(db) + root = library.library_root() + usage = library.disk_usage(root) + return LibraryStats( + titles=len(items), + episodes=sum(i.episode_count for i in items), + size_bytes=sum(i.size_bytes for i in items), + library_path=str(root), + disk_total=usage["total"], + disk_free=usage["free"], + ) diff --git a/backend/services/settings.py b/backend/services/settings.py index e06aa5a..c786d83 100644 --- a/backend/services/settings.py +++ b/backend/services/settings.py @@ -1,57 +1,108 @@ -"""App settings persisted in SQLite (Hakuneko-style descriptor values).""" +"""App settings persisted in SQLite (HakuNeko-style key/value settings). + +``.env`` provides defaults; values saved from the UI override them and are +applied to the running process immediately. +""" from __future__ import annotations import json +import logging from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.config import get_settings from backend.models import AppSetting -from backend.schemas import AppSettingsOut, AppSettingsUpdate +from backend.providers.catalog import DEFAULT_SOURCE, SOURCE_BY_ID, default_enabled_sources +from backend.schemas import QUALITIES, AppSettingsOut, AppSettingsUpdate, dedupe_casefold -DEFAULTS = { - "preferred_voiceovers": None, # filled from env - "default_quality": None, - "steam_deck_optimize": False, - "steam_deck_crf": None, - "steam_deck_height": None, - "default_source": "animego", - "http_proxy": None, -} +logger = logging.getLogger("animebox.settings") async def _get_raw(db: AsyncSession) -> dict: - rows = (await db.execute(select(AppSetting))).scalars().all() raw: dict = {} - for row in rows: + for row in (await db.execute(select(AppSetting))).scalars().all(): try: raw[row.key] = json.loads(row.value) - except Exception: + except ValueError: raw[row.key] = row.value return raw +def _int(value, fallback: int, lo: int, hi: int) -> int: + try: + number = int(value) + except (TypeError, ValueError): + return fallback + return number if lo <= number <= hi else fallback + + async def get_app_settings(db: AsyncSession) -> AppSettingsOut: cfg = get_settings() raw = await _get_raw(db) + voiceovers = raw.get("preferred_voiceovers") + if not isinstance(voiceovers, list): + voiceovers = cfg.preferred_voiceover_list + enabled = raw.get("enabled_sources") + if not isinstance(enabled, list): + enabled = default_enabled_sources() + enabled = [s for s in enabled if s in SOURCE_BY_ID] or [DEFAULT_SOURCE] + default_source = raw.get("default_source") + if default_source not in SOURCE_BY_ID: + default_source = DEFAULT_SOURCE + quality = _int(raw.get("default_quality"), cfg.default_quality, 144, 4320) + proxy = raw.get("http_proxy") if "http_proxy" in raw else cfg.http_proxy return AppSettingsOut( - preferred_voiceovers=raw.get("preferred_voiceovers") or cfg.preferred_voiceover_list, - default_quality=int(raw.get("default_quality") or cfg.default_quality), + preferred_voiceovers=dedupe_casefold([str(v).strip() for v in voiceovers if str(v).strip()]), + default_quality=quality if quality in QUALITIES else 1080, steam_deck_optimize=bool(raw.get("steam_deck_optimize", False)), - steam_deck_crf=int(raw.get("steam_deck_crf") or cfg.steam_deck_crf), - steam_deck_height=int(raw.get("steam_deck_height") or cfg.steam_deck_height), - default_source=str(raw.get("default_source") or "animego"), - http_proxy=raw.get("http_proxy") if raw.get("http_proxy") is not None else cfg.http_proxy, + steam_deck_crf=_int(raw.get("steam_deck_crf"), cfg.steam_deck_crf, 0, 51), + steam_deck_height=_int(raw.get("steam_deck_height"), cfg.steam_deck_height, 240, 2160), + default_source=default_source, + enabled_sources=enabled, + http_proxy=proxy or None, + max_concurrent_downloads=_int(raw.get("max_concurrent_downloads"), cfg.max_concurrent_downloads, 1, 6), + autoplay_next=bool(raw.get("autoplay_next", True)), + remember_volume=bool(raw.get("remember_volume", True)), ) +async def apply_runtime(settings: AppSettingsOut, *, network_changed: bool = False) -> None: + """Push effective settings into the running process.""" + from backend.downloader.manager import download_manager + from backend.providers import get_registry + + cfg = get_settings() + cfg.preferred_voiceovers = ",".join(settings.preferred_voiceovers) + cfg.default_quality = settings.default_quality + cfg.http_proxy = settings.http_proxy or None + cfg.steam_deck_crf = settings.steam_deck_crf + cfg.steam_deck_height = settings.steam_deck_height + cfg.max_concurrent_downloads = settings.max_concurrent_downloads + registry = get_registry() + registry.configure(enabled_sources=settings.enabled_sources, default_source=settings.default_source) + if network_changed: + await registry.apply_network_settings() + download_manager.set_concurrency(settings.max_concurrent_downloads) + + async def update_app_settings(db: AsyncSession, patch: AppSettingsUpdate) -> AppSettingsOut: - current = await get_app_settings(db) - data = current.model_dump() - for key, value in patch.model_dump(exclude_unset=True).items(): - data[key] = value + before = await get_app_settings(db) + changes = patch.model_dump(exclude_unset=True) + if "enabled_sources" in changes and changes["enabled_sources"] is not None: + unknown = [s for s in changes["enabled_sources"] if s not in SOURCE_BY_ID] + if unknown: + raise ValueError(f"Unknown sources: {', '.join(unknown)}") + if not changes["enabled_sources"]: + raise ValueError("Enable at least one source") + if changes.get("default_source") is not None and changes["default_source"] not in SOURCE_BY_ID: + raise ValueError(f"Unknown source: {changes['default_source']}") + for key, value in changes.items(): + if value is None and key != "http_proxy": + continue + if key == "http_proxy": + value = value or None row = await db.get(AppSetting, key) encoded = json.dumps(value) if row is None: @@ -59,18 +110,6 @@ async def update_app_settings(db: AsyncSession, patch: AppSettingsUpdate) -> App else: row.value = encoded await db.commit() - - # Hot-apply proxy / preferences into runtime settings object - cfg = get_settings() - if patch.preferred_voiceovers is not None: - cfg.preferred_voiceovers = ",".join(patch.preferred_voiceovers) - if patch.default_quality is not None: - cfg.default_quality = patch.default_quality - if patch.http_proxy is not None: - cfg.http_proxy = patch.http_proxy or None - if patch.steam_deck_crf is not None: - cfg.steam_deck_crf = patch.steam_deck_crf - if patch.steam_deck_height is not None: - cfg.steam_deck_height = patch.steam_deck_height - - return await get_app_settings(db) + after = await get_app_settings(db) + await apply_runtime(after, network_changed=(before.http_proxy or None) != (after.http_proxy or None)) + return after diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/frontend/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg"> - <symbol id="bluesky-icon" viewBox="0 0 16 17"> - <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g> - <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs> - </symbol> - <symbol id="discord-icon" viewBox="0 0 20 19"> - <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/> - </symbol> - <symbol id="documentation-icon" viewBox="0 0 21 20"> - <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/> - <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/> - <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/> - </symbol> - <symbol id="github-icon" viewBox="0 0 19 19"> - <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/> - </symbol> - <symbol id="social-icon" viewBox="0 0 20 20"> - <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/> - <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/> - </symbol> - <symbol id="x-icon" viewBox="0 0 19 19"> - <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/> - </symbol> -</svg> diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite diff --git a/frontend/src/components/AnimeCard.tsx b/frontend/src/components/AnimeCard.tsx deleted file mode 100644 index 10f0230..0000000 --- a/frontend/src/components/AnimeCard.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Link } from 'react-router-dom' - -type Props = { - id: string - title: string - poster?: string | null - year?: number | null - source?: string - subtitle?: string - to?: string -} - -export function AnimeCard({ id, title, poster, year, source, subtitle, to }: Props) { - const href = to || `/anime/${encodeURIComponent(id)}` - return ( - -
- {poster ? ( - - ) : ( -
- {title.slice(0, 1)} -
- )} -
-
- {source && ( - - {source} - - )} - ▶ -
-
-
-

- {title} -

-

- {[year, subtitle].filter(Boolean).join(' · ') || source} -

-
- - ) -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 43df077..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useState } from 'react' -import { NavLink, Outlet } from 'react-router-dom' -import { api } from '../api/client' - -const links = [ - { to: '/search', label: 'Поиск', short: 'Поиск' }, - { to: '/library', label: 'Моя библиотека', short: 'Библиотека' }, - { to: '/downloads', label: 'Загрузки', short: 'Загрузки' }, - { to: '/settings', label: 'Настройки', short: 'Настройки' }, -] - -export function Layout() { - const [stopping, setStopping] = useState(false) - const [canStop, setCanStop] = useState(false) - - useEffect(() => { - api.session().then((session) => setCanStop(session.shutdown_enabled)).catch(() => {}) - }, []) - - async function stopAnimeBox() { - if (!window.confirm('Остановить AnimeBox? Активные загрузки продолжатся при следующем запуске.')) return - setStopping(true) - try { - await api.shutdown() - } catch (err) { - setStopping(false) - window.alert(err instanceof Error ? err.message : 'Не удалось остановить AnimeBox') - } - } - - return ( -
-
-
- - - A - - - - AnimeBox - - - - - -
- - Local - - {canStop && ( - - )} -
-
- - -
-
- -
-
- ) -} diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx deleted file mode 100644 index 121e614..0000000 --- a/frontend/src/components/ProgressBar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -type Props = { - value: number - className?: string -} - -export function ProgressBar({ value, className = '' }: Props) { - const pct = Math.max(0, Math.min(100, value)) - return ( -
-
-
- ) -} diff --git a/patches/anicli-api.ref b/patches/anicli-api.ref new file mode 100644 index 0000000..098211e --- /dev/null +++ b/patches/anicli-api.ref @@ -0,0 +1 @@ +df4be53eb09b04df09501a335795a19a493168c4 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8b47efc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +filterwarnings = + ignore::DeprecationWarning:anicli_api.* diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a7500b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +"""Test isolation: every backend path points into a throwaway directory. + +Environment variables are set before ``backend`` is imported, because settings +and the SQLAlchemy engine are created at import time. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path + +_ROOT = Path(tempfile.mkdtemp(prefix="animebox-tests-")) +os.environ["DATA_PATH"] = str(_ROOT / "data") +os.environ["LIBRARY_PATH"] = str(_ROOT / "library") +os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{(_ROOT / 'data' / 'animebox.db').as_posix()}" +os.environ["HTTP_PROXY"] = "" +os.environ.setdefault("LOG_LEVEL", "WARNING") + +import httpx # noqa: E402 +import pytest # noqa: E402 +import pytest_asyncio # noqa: E402 + +from backend.config import get_settings # noqa: E402 + + +@pytest.fixture +def settings(): + return get_settings() + + +@pytest.fixture +def library_root(settings) -> Path: + return settings.library_path + + +@pytest_asyncio.fixture +async def db_reset(settings): + """Fresh schema and empty library for each test that touches state.""" + from backend.database.session import Base, engine, init_db + from backend.player.sessions import media_sessions + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + if settings.library_path.exists(): + shutil.rmtree(settings.library_path) + settings.library_path.mkdir(parents=True, exist_ok=True) + await init_db() + media_sessions._sessions.clear() + snapshot = settings.model_dump() + yield + for key, value in snapshot.items(): + setattr(settings, key, value) + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_reset): + """API client against the real app routers (lifespan not started).""" + from fastapi import FastAPI + + from backend.api import router + from backend.player.proxy import router as media_router + + app = FastAPI() + app.include_router(router) + app.include_router(media_router) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1") as http: + yield http + + +def write_media(path: Path, size: int = 4096) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\x00" * size) + return path + + +def pytest_sessionfinish(session, exitstatus): # noqa: ARG001 + shutil.rmtree(_ROOT, ignore_errors=True) diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..06a0c3f --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,73 @@ +"""Deterministic stand-ins for providers (no network in CI).""" + +from __future__ import annotations + +from backend.providers.errors import NotFound, StreamUnavailable +from backend.providers.health import HealthTracker +from backend.providers.registry import ResolvedStream, ResolvedVideo +from backend.schemas import AnimeDetail, EpisodeInfo, SearchResponse, SearchResult, VoiceoverOption + +ANIME_ID = "animego:%2Fanime%2Ffixture-1" + + +class FakeRegistry: + proxy = None + enabled_sources = ["animego"] + default_source = "animego" + + def __init__(self, episodes: int = 3, title: str = "Fixture Anime") -> None: + self.title = title + self.episodes = episodes + self.health = HealthTracker(None) + self.detail_calls = 0 + self.resolve_calls: list[dict] = [] + self.stream = ResolvedVideo(type="m3u8", quality=720, url="https://cdn.example.com/master.m3u8", headers={"User-Agent": "UA"}) + self.fail_stream = False + + async def get_anime(self, external_id: str) -> AnimeDetail: + self.detail_calls += 1 + if external_id != ANIME_ID: + raise NotFound("Anime not found", source="animego", stage="details") + return AnimeDetail( + id=external_id, + title=self.title, + poster="https://img.example.com/poster.jpg", + description="desc", + year=2011, + source="animego", + source_label="AnimeGO", + episodes=[EpisodeInfo(title=f"Episode {i}", ordinal=i) for i in range(1, self.episodes + 1)], + ) + + async def get_episode_voiceovers(self, external_id: str, episode: int) -> list[VoiceoverOption]: + return [VoiceoverOption(title="AniLibria", players=["kodik"], index=0)] + + async def resolve_stream(self, external_id, episode, voiceover=None, quality=None, **kwargs) -> ResolvedStream: + self.resolve_calls.append({"episode": episode, "voiceover": voiceover, "quality": quality, **kwargs}) + if self.fail_stream: + raise StreamUnavailable("No working stream", source="animego", stage="stream", attempts=[]) + return ResolvedStream( + anime_title=self.title, + episode=episode, + voiceover=voiceover or "AniLibria", + player="kodik", + video=self.stream, + qualities=[720, 480], + browser_playable=True, + source="animego", + ) + + async def search(self, query: str, sources=None) -> SearchResponse: + return SearchResponse( + query=query, + results=[SearchResult(id=ANIME_ID, title=self.title, source="animego")], + providers=[], + ) + + def ordered_sources(self): + from backend.providers.catalog import SOURCES + + return list(SOURCES) + + async def aclose(self) -> None: + return None diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..ed01f71 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,148 @@ +"""HTTP API behaviour: progress persistence, error mapping, validation, host protection.""" + +from __future__ import annotations + +import httpx +import pytest +from fakes import ANIME_ID, FakeRegistry + +import backend.providers.registry as registry_module +from backend.downloader.manager import download_manager +from backend.providers.errors import ProviderTimeout + + +@pytest.fixture +def fake_registry(monkeypatch): + fake = FakeRegistry() + monkeypatch.setattr(registry_module, "_registry", fake) + return fake + + +async def test_online_progress_is_saved_and_restorable(client): + body = { + "anime_id": "animego:/anime/fixture-1", # legacy spelling is canonicalised + "anime_title": "Fixture Anime", + "episode": 3, + "voiceover": "AniLibria", + "progress_seconds": 615.5, + "duration_seconds": 1440, + } + assert (await client.post("/api/progress", json=body)).status_code == 200 + body["progress_seconds"] = 700 + saved = (await client.post("/api/progress", json=body)).json() + assert saved["anime_external_id"] == ANIME_ID and saved["progress_seconds"] == 700 and not saved["completed"] + + restored = (await client.get("/api/history/anime", params={"id": ANIME_ID})).json() + assert [(h["episode"], h["progress_seconds"], h["voiceover"]) for h in restored] == [(3, 700, "AniLibria")] + + home = (await client.get("/api/home")).json() + assert [h["episode"] for h in home["continue_watching"]] == [3] + assert home["recently_downloaded"] == [] + + +async def test_finished_episode_leaves_continue_watching(client): + await client.post("/api/progress", json={"anime_id": ANIME_ID, "episode": 1, "progress_seconds": 1400, "duration_seconds": 1440}) + home = (await client.get("/api/home")).json() + assert home["continue_watching"] == [] + assert home["recently_watched"][0]["completed"] is True + + +@pytest.mark.parametrize( + "payload", + [ + {"anime_id": ANIME_ID, "episode": 1, "progress_seconds": -5}, + {"anime_id": ANIME_ID, "episode": -1, "progress_seconds": 5}, + {"episode": 1, "progress_seconds": 5}, + ], +) +async def test_progress_validation(client, payload): + assert (await client.post("/api/progress", json=payload)).status_code in (400, 422) + + +async def test_invalid_ids_are_rejected(client, fake_registry): + response = await client.get("/api/anime", params={"id": "no-colon"}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_id" + + +async def test_provider_errors_map_to_structured_responses(client, fake_registry): + missing = await client.get("/api/anime", params={"id": "animego:%2Fanime%2Funknown"}) + assert missing.status_code == 404 and missing.json()["detail"]["stage"] == "details" + + async def slow(_id): + raise ProviderTimeout("details timed out after 25s", source="animego", stage="details") + + fake_registry.get_anime = slow + timeout = await client.get("/api/anime", params={"id": ANIME_ID}) + assert timeout.status_code == 504 + assert timeout.json()["detail"] == { + "code": "provider_timeout", + "message": "details timed out after 25s", + "source": "animego", + "stage": "details", + } + + +async def test_stream_resolve_returns_scoped_media_url(client, fake_registry): + response = await client.post("/api/stream/resolve", json={"anime_id": ANIME_ID, "episode": 2, "voiceover": "AniLibria"}) + assert response.status_code == 200 + data = response.json() + assert data["url"].startswith("/api/media/") and "cdn.example.com" not in data["url"] + assert data["player"] == "kodik" and data["qualities"] == [720, 480] + + fake_registry.fail_stream = True + failed = await client.post("/api/stream/resolve", json={"anime_id": ANIME_ID, "episode": 2}) + assert failed.status_code == 502 and failed.json()["detail"]["code"] == "stream_unavailable" + + +async def test_download_api_queues_season_and_reports_skips(client, fake_registry, monkeypatch): + monkeypatch.setattr(download_manager, "_wakeup", __import__("asyncio").Event()) + first = (await client.post("/api/downloads", json={"anime_id": ANIME_ID, "episodes": [1, 2]})).json() + assert [j["episode"] for j in first["jobs"]] == [1, 2] and first["skipped"] == [] + season = (await client.post("/api/downloads", json={"anime_id": ANIME_ID})).json() + assert [j["episode"] for j in season["jobs"]] == [3] + assert {s["episode"]: s["reason"] for s in season["skipped"]} == {1: "already_queued", 2: "already_queued"} + + bad = await client.post("/api/downloads", json={"anime_id": ANIME_ID, "episodes": [99]}) + assert bad.status_code == 404 + active = (await client.get("/api/downloads", params={"status": "active"})).json() + assert len(active) == 3 + # Queuing never makes a title appear in the offline library. + assert (await client.get("/api/library")).json() == [] + + +async def test_settings_validation_and_roundtrip(client): + current = (await client.get("/api/settings")).json() + assert current["default_source"] == "animego" and "animego" in current["enabled_sources"] + + assert (await client.put("/api/settings", json={"default_quality": 999})).status_code == 422 + assert (await client.put("/api/settings", json={"http_proxy": "javascript:alert(1)"})).status_code == 422 + assert (await client.put("/api/settings", json={"enabled_sources": ["hdrezka"]})).status_code == 422 + assert (await client.put("/api/settings", json={"max_concurrent_downloads": 50})).status_code == 422 + + updated = await client.put( + "/api/settings", + json={"default_quality": 720, "preferred_voiceovers": ["AniDUB", " AniDUB ", "AniLibria"], "max_concurrent_downloads": 3}, + ) + assert updated.status_code == 200 + data = updated.json() + assert data["default_quality"] == 720 + assert data["preferred_voiceovers"] == ["AniDUB", "AniLibria"] + assert data["max_concurrent_downloads"] == 3 + + +async def test_providers_are_listed_with_honest_status(client): + providers = (await client.get("/api/providers")).json() + assert providers[0]["id"] == "animego" + assert all(p["status"] in {"unknown", "partial", "ok", "degraded", "down"} for p in providers) + assert "hdrezka" not in {p["id"] for p in providers} + + +async def test_main_app_rejects_foreign_host_headers(db_reset): + from backend.main import app + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1") as http: + assert (await http.get("/api/health")).json()["version"] == "2.0.0" + rebinding = await http.get("/api/session", headers={"Host": "attacker.example:8787"}) + assert rebinding.status_code == 400 + assert (await http.get("/api/does-not-exist")).status_code == 404 diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py deleted file mode 100644 index 929aa05..0000000 --- a/tests/test_api_routes.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest -from fastapi import FastAPI - -from backend.api import routes -from backend.schemas import VoiceoverOption - - -@pytest.mark.asyncio -async def test_episode_voiceovers_route_is_not_shadowed_by_anime_detail( - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[tuple[str, int]] = [] - - class FakeRegistry: - async def get_episode_voiceovers(self, anime_id: str, episode: int) -> list[VoiceoverOption]: - calls.append((anime_id, episode)) - return [ - VoiceoverOption( - title="AniLibria", - url="https://player.example/episode-1", - player_host="player.example", - index=0, - ) - ] - - monkeypatch.setattr(routes, "get_registry", lambda: FakeRegistry()) - app = FastAPI() - app.include_router(routes.router) - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://test", - ) as client: - response = await client.get("/api/anime/animego:fixture/episodes/1/voiceovers") - - assert response.status_code == 200 - assert response.json() == [ - { - "title": "AniLibria", - "url": "https://player.example/episode-1", - "player_host": "player.example", - "index": 0, - } - ] - assert calls == [("animego:fixture", 1)] diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..d579736 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,298 @@ +"""Download manager: queue rules, validation, atomic finalisation, recovery, concurrency.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import httpx +import pytest +from conftest import write_media +from fakes import ANIME_ID, FakeRegistry +from sqlalchemy import select + +import backend.providers.registry as registry_module +from backend.database.session import SessionLocal +from backend.downloader import manager as manager_module +from backend.downloader.manager import DownloadManager +from backend.models import DownloadJob, LibraryEpisode +from backend.player import sessions +from backend.providers.errors import NotFound +from backend.providers.http import media_clients +from backend.services import ffmpeg_tools, library + +EPISODE_SECONDS = 1440.0 + + +@pytest.fixture +def fake_registry(monkeypatch): + fake = FakeRegistry() + monkeypatch.setattr(registry_module, "_registry", fake) + + async def resolve(host, _port): + return ["93.184.216.34"] + + monkeypatch.setattr(sessions, "_resolve", resolve) + sessions._dns_cache.clear() + return fake + + +@pytest.fixture +def ffmpeg(monkeypatch): + """Fake ffmpeg: writes the output file and reports real-looking progress.""" + state = {"calls": [], "duration": EPISODE_SECONDS, "fail": None, "block": None} + + async def fake_download(url, dest: Path, headers=None, *, on_progress=None, hls=False): + state["calls"].append({"url": url, "dest": dest, "hls": hls}) + if state["block"] is not None: + await state["block"].wait() + if state["fail"]: + raise RuntimeError(state["fail"]) + write_media(dest, 8192) + if on_progress: + on_progress(ffmpeg_tools.FfmpegProgress(out_seconds=EPISODE_SECONDS / 2, total_size=4096, duration=EPISODE_SECONDS)) + return dest + + async def fake_probe(path: Path): + return state["duration"] + + monkeypatch.setattr(ffmpeg_tools, "download_stream_with_ffmpeg", fake_download) + monkeypatch.setattr(ffmpeg_tools, "probe_duration", fake_probe) + monkeypatch.setattr(ffmpeg_tools, "ffmpeg_available", lambda: True) + monkeypatch.setattr(manager_module, "RETRY_DELAYS", (0.0, 0.0)) + return state + + +@pytest.fixture +def no_poster(monkeypatch): + async def save_poster(*_args, **_kwargs): + return None + + monkeypatch.setattr(library, "save_poster", save_poster) + + +async def enqueue(manager: DownloadManager, episode: int = 1, **kwargs): + return await manager.enqueue( + anime_id=ANIME_ID, + anime_title="Fixture Anime", + source="animego", + season=1, + episode=episode, + episode_title=f"Episode {episode}", + voiceover=kwargs.pop("voiceover", "AniLibria"), + steam_deck=kwargs.pop("steam_deck", False), + **kwargs, + ) + + +async def job(job_id: int) -> DownloadJob: + async with SessionLocal() as db: + return await db.get(DownloadJob, job_id) + + +async def library_rows() -> list[LibraryEpisode]: + async with SessionLocal() as db: + return list((await db.execute(select(LibraryEpisode))).scalars().all()) + + +async def test_successful_hls_download_goes_through_local_proxy_and_lands_atomically( + db_reset, fake_registry, ffmpeg, no_poster, library_root +): + manager = DownloadManager() + created, reason = await enqueue(manager) + assert reason is None + await manager._process(created.id) + + done = await job(created.id) + assert done.status == "completed" and done.progress == 100.0 and done.error is None + final = library_root / "Fixture Anime" / "Season 1" / "Episode 01.mp4" + assert final.is_file() + assert not list(library_root.rglob("*.part.*")) + call = ffmpeg["calls"][0] + assert call["hls"] and call["url"].startswith("http://127.0.0.1:") and "/api/media/" in call["url"] + assert call["dest"].name == "Episode 01.part.mp4" + rows = await library_rows() + assert [(r.episode, r.voiceover, r.quality, r.file_path) for r in rows] == [ + (1, "AniLibria", 720, "Fixture Anime/Season 1/Episode 01.mp4") + ] + assert library.read_metadata(final.parent.parent)["id"] == ANIME_ID + assert fake_registry.resolve_calls[0]["purpose"] == "download" + + +async def test_duplicate_requests_are_skipped(db_reset, fake_registry, ffmpeg, no_poster): + manager = DownloadManager() + first, _ = await enqueue(manager) + assert (await enqueue(manager))[1] == "already_queued" + await manager._process(first.id) + assert (await enqueue(manager))[1] == "already_downloaded" + replaced, reason = await enqueue(manager, replace=True, voiceover="AniDUB") + assert reason is None and replaced is not None + + +async def test_incomplete_download_fails_without_library_entry(db_reset, fake_registry, ffmpeg, no_poster, library_root): + ffmpeg["duration"] = EPISODE_SECONDS * 0.5 # truncated stream + manager = DownloadManager() + created, _ = await enqueue(manager) + await manager._process(created.id) + + failed = await job(created.id) + assert failed.status == "failed" and "incomplete" in failed.error.lower() + assert failed.attempts == manager_module.MAX_ATTEMPTS + assert await library_rows() == [] + assert not list(library_root.rglob("*.mp4")) + assert not (library_root / "Fixture Anime").exists() + + +async def test_transient_failure_is_retried_with_fresh_resolution(db_reset, fake_registry, ffmpeg, no_poster): + manager = DownloadManager() + created, _ = await enqueue(manager) + original = ffmpeg_tools.download_stream_with_ffmpeg + attempts = {"n": 0} + + async def flaky(*args, **kwargs): + attempts["n"] += 1 + if attempts["n"] == 1: + raise RuntimeError("ffmpeg failed with exit code 1: HTTP error 403") + return await original(*args, **kwargs) + + ffmpeg_tools.download_stream_with_ffmpeg = flaky + try: + await manager._process(created.id) + finally: + ffmpeg_tools.download_stream_with_ffmpeg = original + assert (await job(created.id)).status == "completed" + assert [c.get("refresh") for c in fake_registry.resolve_calls] == [False, True] + + +async def test_missing_episode_fails_immediately(db_reset, fake_registry, ffmpeg, no_poster): + async def gone(*_a, **_k): + raise NotFound("Episode 1 not found", source="animego", stage="episodes") + + fake_registry.resolve_stream = gone + manager = DownloadManager() + created, _ = await enqueue(manager) + await manager._process(created.id) + failed = await job(created.id) + assert failed.status == "failed" and failed.attempts == 1 + + +async def test_poster_failure_does_not_fail_valid_download(db_reset, fake_registry, ffmpeg, monkeypatch): + async def broken_poster(*_args, **_kwargs): + raise OSError("disk full while writing poster") + + monkeypatch.setattr(library, "save_poster", broken_poster) + manager = DownloadManager() + created, _ = await enqueue(manager) + async with SessionLocal() as db: + from backend.services import library_index + + await library_index.upsert_title(db, anime_id=ANIME_ID, source="animego", title="Fixture Anime", poster="https://img.example.com/p.jpg") + await db.commit() + await manager._process(created.id) + done = await job(created.id) + assert done.status == "completed" + assert "poster" in (done.warning or "") + assert len(await library_rows()) == 1 + + +async def test_steam_deck_failure_keeps_original_file(db_reset, fake_registry, ffmpeg, no_poster, monkeypatch, library_root): + async def failing_transcode(src, dest, on_progress=None): + raise RuntimeError("libx265 missing") + + monkeypatch.setattr(ffmpeg_tools, "optimize_for_steam_deck", failing_transcode) + manager = DownloadManager() + created, _ = await enqueue(manager, steam_deck=True) + await manager._process(created.id) + done = await job(created.id) + assert done.status == "completed" and "Steam Deck optimization failed" in done.warning + assert (library_root / "Fixture Anime" / "Season 1" / "Episode 01.mp4").is_file() + + +async def test_recovery_requeues_interrupted_jobs_and_cleans_orphan_parts(db_reset, library_root): + orphan = write_media(library_root / "Old" / "Season 1" / "Episode 03.part.mp4") + keep_final = library_root / "Keep" / "Season 1" / "Episode 01.mp4" + kept_part = write_media(library.partial_path(keep_final)) + (library_root / "Browsed").mkdir() + async with SessionLocal() as db: + db.add_all( + [ + DownloadJob(anime_external_id=ANIME_ID, anime_title="A", source="animego", season=1, episode=1, + status="downloading", progress=40, bytes_done=10), + DownloadJob(anime_external_id=ANIME_ID, anime_title="Keep", source="animevost", season=1, episode=2, + status="paused", progress=30, resumable=True, file_path=library.to_stored_path(keep_final)), + ] + ) + await db.commit() + await DownloadManager()._recover() + + async with SessionLocal() as db: + jobs = {j.episode: j for j in (await db.execute(select(DownloadJob))).scalars().all()} + assert (jobs[1].status, jobs[1].progress) == ("queued", 0) + assert (jobs[2].status, jobs[2].progress) == ("paused", 30) + assert not orphan.exists() and kept_part.exists() + assert not (library_root / "Browsed").exists() and not (library_root / "Old").exists() + + +async def test_scheduler_respects_concurrency_and_shutdown_requeues(db_reset, fake_registry, ffmpeg, no_poster): + gate = asyncio.Event() + ffmpeg["block"] = gate + manager = DownloadManager() + manager.set_concurrency(2) + for number in (1, 2, 3): + await enqueue(manager, episode=number) + await manager.start() + for _ in range(100): + if len(manager.running_ids()) == 2: + break + await asyncio.sleep(0.02) + assert len(manager.running_ids()) == 2 + await manager.stop() + async with SessionLocal() as db: + statuses = sorted(j.status for j in (await db.execute(select(DownloadJob))).scalars().all()) + assert statuses == ["queued", "queued", "queued"] + + +async def test_cancel_queued_and_retry_rules(db_reset, fake_registry): + manager = DownloadManager() + created, _ = await enqueue(manager) + assert await manager.retry(created.id) is False # only failed/cancelled can be retried + assert await manager.cancel(created.id) is True + assert (await job(created.id)).status == "cancelled" + assert await manager.retry(created.id) is True + assert (await job(created.id)).status == "queued" + assert await manager.pause(created.id) is False # HLS/unknown jobs are not resumable + + +async def test_progressive_download_resumes_with_range(db_reset, fake_registry, ffmpeg, no_poster, monkeypatch, library_root): + payload = bytes(range(256)) * 64 # 16 KiB + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + start = int(request.headers.get("range", "bytes=0-").split("=")[1].split("-")[0]) + body = payload[start:] + status = 206 if start else 200 + headers = {"content-length": str(len(body)), "content-type": "video/mp4"} + if start: + headers["content-range"] = f"bytes {start}-{len(payload) - 1}/{len(payload)}" + return httpx.Response(status, headers=headers, stream=httpx.ByteStream(body)) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr(media_clients, "get", lambda _proxy: client) + fake_registry.stream = registry_module.ResolvedVideo(type="mp4", quality=720, url="https://cdn.example.com/ep.mp4") + + final = library_root / "Fixture Anime" / "Season 1" / "Episode 01.mp4" + write_media(library.partial_path(final), 0) + library.partial_path(final).write_bytes(payload[:4096]) + manager = DownloadManager() + created, _ = await enqueue(manager) + async with SessionLocal() as db: + row = await db.get(DownloadJob, created.id) + row.resumable, row.file_path, row.source_url = True, library.to_stored_path(final), "https://cdn.example.com/ep.mp4" + row.bytes_total = len(payload) + await db.commit() + await manager._process(created.id) + + assert (await job(created.id)).status == "completed" + assert seen[0].headers["range"] == "bytes=4096-" + assert final.read_bytes() == payload diff --git a/tests/test_ffmpeg.py b/tests/test_ffmpeg.py new file mode 100644 index 0000000..22da3fc --- /dev/null +++ b/tests/test_ffmpeg.py @@ -0,0 +1,166 @@ +"""ffmpeg integration: binary discovery, error reporting, progress parsing, process cleanup.""" + +from __future__ import annotations + +import asyncio +import importlib +import os +import stat +from pathlib import Path + +import pytest + +from backend.providers.catalog import SOURCES +from backend.services import ffmpeg_tools + + +def test_all_catalog_sources_import() -> None: + for spec in SOURCES: + module = importlib.import_module(f"anicli_api.source.{spec.module}") + assert hasattr(module, "Extractor") and hasattr(module, "Search") + + +class FakeStream: + def __init__(self, lines: list[bytes], block: bool = False) -> None: + self._lines = list(lines) + self._block = block + + async def readline(self) -> bytes: + if self._lines: + return self._lines.pop(0) + if self._block: + await asyncio.Event().wait() + return b"" + + +class FakeProcess: + def __init__(self, returncode: int = 0, stdout: list[bytes] | None = None, stderr: list[bytes] | None = None, block=False): + self._final = returncode + self.returncode: int | None = None + self.stdout = FakeStream(stdout or [], block) + self.stderr = FakeStream(stderr or [], block) + self.terminated = False + + async def wait(self) -> int: + self.returncode = self._final if self.returncode is None else self.returncode + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.returncode = -15 + + def kill(self) -> None: + self.returncode = -9 + + +async def test_stream_download_copies_and_lets_ffmpeg_pick_best_variant(monkeypatch, tmp_path: Path) -> None: + captured: list[str] = [] + + async def fake_run(args, on_progress=None): + captured.extend(args) + return "" + + monkeypatch.setattr(ffmpeg_tools, "run_ffmpeg", fake_run) + monkeypatch.setattr(ffmpeg_tools, "hls_input_options", lambda: ["-allowed_extensions", "ALL"]) + await ffmpeg_tools.download_stream_with_ffmpeg( + "https://cdn.example/master.m3u8", + tmp_path / "episode.part.mp4", + {"User-Agent": "Provider-UA", "Referer": "https://player.example/"}, + hls=True, + ) + assert "-map" not in captured + assert captured[captured.index("-i") + 1] == "https://cdn.example/master.m3u8" + assert captured[captured.index("-c") + 1] == "copy" + assert captured[captured.index("-user_agent") + 1] == "Provider-UA" + assert captured[captured.index("-headers") + 1] == "Referer: https://player.example/\r\n" + assert captured.index("-allowed_extensions") < captured.index("-i") + assert captured[captured.index("-progress") + 1] == "pipe:1" + + +async def test_progress_is_parsed_from_machine_output(monkeypatch) -> None: + proc = FakeProcess( + stdout=[b"out_time_us=600000000\n", b"total_size=1048576\n", b"progress=continue\n", b"progress=end\n"], + stderr=[b" Duration: 00:20:00.00, start: 0.0, bitrate: 1 kb/s\n"], + ) + + async def spawn(*_args, **_kwargs): + return proc + + monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: "ffmpeg") + monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", spawn) + seen: list[ffmpeg_tools.FfmpegProgress] = [] + # stderr is read concurrently; duration may arrive before or after the first progress block. + await ffmpeg_tools.run_ffmpeg(["-i", "x"], on_progress=seen.append) + assert seen and seen[0].out_seconds == 600 and seen[0].total_size == 1048576 + assert any(p.fraction == pytest.approx(0.5) for p in seen if p.duration) + + +def test_parse_duration() -> None: + assert ffmpeg_tools.parse_duration("Duration: 01:02:03.50, start") == pytest.approx(3723.5) + assert ffmpeg_tools.parse_duration("Duration: N/A") is None + + +@pytest.mark.skipif(os.name == "nt", reason="Windows does not use POSIX execute bits") +def test_bundled_ffmpeg_repairs_only_user_execute_bit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + executable = tmp_path / "tools" / "ffmpeg" + executable.parent.mkdir() + executable.write_bytes(b"ffmpeg") + executable.chmod(0o640) + monkeypatch.setattr(ffmpeg_tools, "ROOT_DIR", tmp_path) + + assert ffmpeg_tools.ffmpeg_path() == str(executable) + assert stat.S_IMODE(executable.stat().st_mode) == 0o740 + + +def test_ffmpeg_from_path_is_not_modified(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + executable = tmp_path / "system-ffmpeg" + executable.write_bytes(b"ffmpeg") + executable.chmod(0o755) + initial_mode = stat.S_IMODE(executable.stat().st_mode) + monkeypatch.setattr(ffmpeg_tools, "ROOT_DIR", tmp_path / "portable") + monkeypatch.setattr(ffmpeg_tools.shutil, "which", lambda _name: str(executable)) + + assert ffmpeg_tools.ffmpeg_path() == str(executable) + assert stat.S_IMODE(executable.stat().st_mode) == initial_mode + + +async def test_ffmpeg_launch_error_includes_path_and_macos_helper(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + executable = tmp_path / "tools" / "ffmpeg" + monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: str(executable)) + monkeypatch.setattr(ffmpeg_tools, "bundled_ffmpeg_path", lambda: executable) + monkeypatch.setattr(ffmpeg_tools.sys, "platform", "darwin") + + async def fail_to_start(*_args, **_kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", fail_to_start) + with pytest.raises(RuntimeError) as exc_info: + await ffmpeg_tools.run_ffmpeg(["-version"]) + message = str(exc_info.value) + assert str(executable) in message and "Permission denied" in message and "grant-macos-permissions.sh" in message + + +async def test_ffmpeg_exit_error_includes_code_and_stderr(monkeypatch: pytest.MonkeyPatch) -> None: + async def spawn(*_args, **_kwargs): + return FakeProcess(returncode=7, stderr=[b"broken dylib\n"]) + + monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: "/usr/local/bin/ffmpeg") + monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", spawn) + with pytest.raises(RuntimeError, match="exit code 7.*broken dylib"): + await ffmpeg_tools.run_ffmpeg(["-version"]) + + +async def test_ffmpeg_process_is_terminated_when_download_is_cancelled(monkeypatch: pytest.MonkeyPatch) -> None: + process = FakeProcess(block=True) + + async def spawn(*_args, **_kwargs): + return process + + monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: "ffmpeg") + monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", spawn) + task = asyncio.create_task(ffmpeg_tools.run_ffmpeg(["-i", "x"])) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert process.terminated is True diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..face02a --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,165 @@ +"""Regression tests for the 1.x "empty anime in library" bug and library/file integrity.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import write_media +from fakes import ANIME_ID, FakeRegistry +from sqlalchemy import select + +import backend.providers.registry as registry_module +from backend.database.session import SessionLocal +from backend.models import AnimeTitle, LibraryEpisode +from backend.services import library + + +@pytest.fixture +def fake_registry(monkeypatch): + fake = FakeRegistry() + monkeypatch.setattr(registry_module, "_registry", fake) + return fake + + +async def add_episode(anime_id: str, title: str, episode: int, root: Path, *, create_file: bool = True, size: int = 4096): + path = library.episode_file_path(title, 1, episode, "mp4", root) + if create_file: + write_media(path, size) + async with SessionLocal() as db: + db.add( + LibraryEpisode( + anime_external_id=anime_id, + season=1, + episode=episode, + title=f"Episode {episode}", + file_path=library.to_stored_path(path, root), + ) + ) + if episode == 1: + db.add(AnimeTitle(external_id=anime_id, source="animego", title=title)) + await db.commit() + return path + + +async def test_library_is_empty_initially(client): + response = await client.get("/api/library") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_opening_details_creates_no_directory_and_no_library_entry(client, fake_registry, library_root): + response = await client.get("/api/anime", params={"id": ANIME_ID}) + assert response.status_code == 200 + assert response.json()["title"] == "Fixture Anime" + + assert list(library_root.iterdir()) == [] # no folder side effect + assert (await client.get("/api/library")).json() == [] + + async with SessionLocal() as db: # catalog metadata may be cached, but never as library content + row = (await db.execute(select(AnimeTitle).where(AnimeTitle.external_id == ANIME_ID))).scalar_one() + assert row.folder_path is None + + +async def test_double_encoded_id_resolves_to_same_title(client, fake_registry): + legacy = "animego:/anime/fixture-1" # how 1.x frontends sent ids after decoding twice + response = await client.get("/api/anime", params={"id": legacy}) + assert response.status_code == 200 + assert response.json()["id"] == ANIME_ID + + +async def test_search_and_history_do_not_populate_library(client, fake_registry, library_root): + assert (await client.get("/api/search", params={"q": "fixture"})).status_code == 200 + saved = await client.post( + "/api/progress", json={"anime_id": ANIME_ID, "anime_title": "Fixture Anime", "episode": 1, "progress_seconds": 30} + ) + assert saved.status_code == 200 + assert (await client.get("/api/library")).json() == [] + assert list(library_root.iterdir()) == [] + + +async def test_library_lists_only_titles_with_existing_valid_files(client, library_root): + await add_episode(ANIME_ID, "Fixture Anime", 1, library_root) + await add_episode(ANIME_ID, "Fixture Anime", 2, library_root, create_file=False) # deleted by the user + await add_episode("animego:%2Fanime%2Fghost-2", "Ghost", 1, library_root, create_file=False) + await add_episode("animego:%2Fanime%2Ftiny-3", "Tiny", 1, library_root, size=10) # truncated file + + items = (await client.get("/api/library")).json() + assert [item["id"] for item in items] == [ANIME_ID] + assert items[0]["episode_count"] == 1 + assert items[0]["missing_count"] == 1 + assert items[0]["size_bytes"] == 4096 + + episodes = (await client.get("/api/library/episodes", params={"id": ANIME_ID})).json() + assert [(e["episode"], e["available"]) for e in episodes] == [(1, True), (2, False)] + assert episodes[1]["stream_url"] is None + + +async def test_partial_file_is_not_library_content(client, library_root): + path = library.episode_file_path("Fixture Anime", 1, 1, "mp4", library_root) + write_media(library.partial_path(path)) + async with SessionLocal() as db: + db.add( + LibraryEpisode( + anime_external_id=ANIME_ID, + season=1, + episode=1, + file_path=library.to_stored_path(library.partial_path(path), library_root), + ) + ) + await db.commit() + assert (await client.get("/api/library")).json() == [] + + +async def test_missing_file_returns_404_and_existing_file_supports_range(client, library_root): + await add_episode(ANIME_ID, "Fixture Anime", 1, library_root) + episodes = (await client.get("/api/library/episodes", params={"id": ANIME_ID})).json() + ranged = await client.get(episodes[0]["stream_url"], headers={"Range": "bytes=0-99"}) + assert ranged.status_code == 206 + assert ranged.headers["content-type"].startswith("video/mp4") + + library.resolve_stored_path(f"Fixture Anime/Season 1/Episode 01.mp4", library_root).unlink() + gone = await client.get(episodes[0]["stream_url"]) + assert gone.status_code == 404 + assert (await client.get("/api/library")).json() == [] + + +async def test_delete_anime_removes_files_rows_and_folder(client, library_root): + path = await add_episode(ANIME_ID, "Fixture Anime", 1, library_root) + (path.parent.parent / "poster.jpg").write_bytes(b"jpg") + response = await client.delete("/api/library/anime", params={"id": ANIME_ID}) + assert response.status_code == 200 + assert not path.exists() + assert not (library_root / "Fixture Anime").exists() + assert (await client.get("/api/library")).json() == [] + assert (await client.delete("/api/library/anime", params={"id": ANIME_ID})).status_code == 404 + + +async def test_rescan_prunes_missing_rows_and_imports_tracked_folders(client, library_root): + await add_episode(ANIME_ID, "Fixture Anime", 1, library_root, create_file=False) + orphan = write_media(library.episode_file_path("Other", 1, 4, "mp4", library_root)) + library.write_metadata(orphan.parent.parent, {"id": "animego:/anime/other-9", "title": "Other"}) + (library_root / "Empty Browse Folder").mkdir() # left behind by AnimeBox 1.x + + result = (await client.post("/api/library/rescan")).json() + assert result == {"removed_missing": 1, "imported": 1, "removed_empty_dirs": 1} + items = (await client.get("/api/library")).json() + assert [(i["id"], i["title"], i["episode_count"]) for i in items] == [ + ("animego:%2Fanime%2Fother-9", "Other", 1) + ] + assert orphan.exists() + + +def test_path_helpers_are_pure(library_root): + target = library.episode_file_path("New Title", 2, 3, "mp4", library_root) + assert target == library_root / "New Title" / "Season 2" / "Episode 03.mp4" + assert not (library_root / "New Title").exists() + + +def test_legacy_relative_paths_resolve(monkeypatch, tmp_path): + app_root = tmp_path / "app" + root = app_root / "library" + write_media(root / "Show" / "Season 1" / "Episode 01.mp4") + monkeypatch.setattr(library, "ROOT_DIR", app_root) + assert library.resolve_stored_path("library\\Show\\Season 1\\Episode 01.mp4", root).is_file() + assert library.resolve_stored_path("Show/Season 1/Episode 01.mp4", root).is_file() diff --git a/tests/test_media_pipeline.py b/tests/test_media_pipeline.py deleted file mode 100644 index 4551248..0000000 --- a/tests/test_media_pipeline.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import os -from pathlib import Path -import stat - -import pytest - -from backend.downloader.manager import DownloadManager -from backend.player.proxy import _rewrite_m3u8 -from backend.services import ffmpeg_tools -from backend.config import Settings -from backend.providers.registry import DEFAULT_SOURCES, _pick_video, load_extractor - - -def test_all_default_search_sources_import() -> None: - settings = Settings(_env_file=None) - - for source in DEFAULT_SOURCES: - extractor, module = load_extractor(source, settings) - assert extractor is not None - assert module.__name__ == f"anicli_api.source.{source}" - - -@dataclass -class FakeVideo: - type: str - quality: int - url: str - - -def test_pick_video_prefers_hls_when_quality_matches() -> None: - videos = [ - FakeVideo("mpd", 1080, "https://cdn.example/video.mpd"), - FakeVideo("m3u8", 1080, "https://cdn.example/video.m3u8"), - ] - - picked = _pick_video(videos, 1080) - - assert picked.type == "m3u8" - - -@pytest.mark.asyncio -async def test_stream_download_lets_ffmpeg_pick_highest_quality(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - captured: list[str] = [] - - async def fake_run_ffmpeg(args: list[str]) -> None: - captured.extend(args) - - monkeypatch.setattr(ffmpeg_tools, "run_ffmpeg", fake_run_ffmpeg) - - await ffmpeg_tools.download_stream_with_ffmpeg( - "https://cdn.example/master.m3u8", - tmp_path / "episode.mp4", - ) - - assert "-map" not in captured - assert captured[captured.index("-i") + 1] == "https://cdn.example/master.m3u8" - assert captured[captured.index("-c") + 1] == "copy" - - -@pytest.mark.skipif(os.name == "nt", reason="Windows does not use POSIX execute bits") -def test_bundled_ffmpeg_repairs_only_user_execute_bit( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - executable = tmp_path / "tools" / "ffmpeg" - executable.parent.mkdir() - executable.write_bytes(b"ffmpeg") - executable.chmod(0o640) - monkeypatch.setattr(ffmpeg_tools, "ROOT_DIR", tmp_path) - - assert ffmpeg_tools.ffmpeg_path() == str(executable) - assert executable.stat().st_mode & stat.S_IXUSR - assert stat.S_IMODE(executable.stat().st_mode) == 0o740 - - -def test_ffmpeg_from_path_is_not_modified(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - executable = tmp_path / "system-ffmpeg" - executable.write_bytes(b"ffmpeg") - executable.chmod(0o755) - initial_mode = stat.S_IMODE(executable.stat().st_mode) - monkeypatch.setattr(ffmpeg_tools, "ROOT_DIR", tmp_path / "portable") - monkeypatch.setattr(ffmpeg_tools.shutil, "which", lambda _name: str(executable)) - - assert ffmpeg_tools.ffmpeg_path() == str(executable) - assert stat.S_IMODE(executable.stat().st_mode) == initial_mode - - -@pytest.mark.asyncio -async def test_ffmpeg_launch_error_includes_path_and_macos_helper( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - executable = tmp_path / "tools" / "ffmpeg" - executable.parent.mkdir() - executable.write_bytes(b"ffmpeg") - executable.chmod(0o755) - monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: str(executable)) - monkeypatch.setattr(ffmpeg_tools, "bundled_ffmpeg_path", lambda: executable) - monkeypatch.setattr(ffmpeg_tools.sys, "platform", "darwin") - - async def fail_to_start(*_args, **_kwargs): - raise PermissionError(13, "Permission denied") - - monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", fail_to_start) - - with pytest.raises(RuntimeError) as exc_info: - await ffmpeg_tools.run_ffmpeg(["-version"]) - - message = str(exc_info.value) - assert str(executable) in message - assert "Permission denied" in message - assert "grant-macos-permissions.sh" in message - - -@pytest.mark.asyncio -async def test_ffmpeg_exit_error_includes_code_and_stderr(monkeypatch: pytest.MonkeyPatch) -> None: - class FakeProcess: - returncode = 7 - - async def communicate(self): - return b"", b"broken dylib" - - async def fake_create_subprocess_exec(*_args, **_kwargs): - return FakeProcess() - - monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: "/usr/local/bin/ffmpeg") - monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) - - with pytest.raises(RuntimeError, match="exit code 7.*broken dylib"): - await ffmpeg_tools.run_ffmpeg(["-version"]) - - -@pytest.mark.asyncio -async def test_ffmpeg_process_is_terminated_when_download_is_cancelled(monkeypatch: pytest.MonkeyPatch) -> None: - class FakeProcess: - returncode: int | None = None - terminated = False - - async def communicate(self): - raise __import__("asyncio").CancelledError - - def terminate(self) -> None: - self.terminated = True - self.returncode = -15 - - async def wait(self) -> int: - return self.returncode or 0 - - def kill(self) -> None: - self.returncode = -9 - - process = FakeProcess() - - async def fake_create_subprocess_exec(*_args, **_kwargs): - return process - - monkeypatch.setattr(ffmpeg_tools, "ffmpeg_path", lambda: "ffmpeg") - monkeypatch.setattr(ffmpeg_tools.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) - - with pytest.raises(__import__("asyncio").CancelledError): - await ffmpeg_tools.run_ffmpeg(["-version"]) - - assert process.terminated is True - - -def test_rewrite_m3u8_rewrites_segments_and_uri_attributes() -> None: - playlist = """#EXTM3U -#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin" -#EXT-X-MAP:URI="init.mp4" -#EXTINF:5, -segment-1.ts -""" - - rewritten = _rewrite_m3u8(playlist, "https://cdn.example/show/master.m3u8", "headers-id") - - assert "URI=\"/api/proxy/stream?url=https%3A%2F%2Fcdn.example%2Fshow%2Fkeys%2Fkey.bin&hid=headers-id\"" in rewritten - assert "URI=\"/api/proxy/stream?url=https%3A%2F%2Fcdn.example%2Fshow%2Finit.mp4&hid=headers-id\"" in rewritten - assert "/api/proxy/stream?url=https%3A%2F%2Fcdn.example%2Fshow%2Fsegment-1.ts&hid=headers-id" in rewritten - - -@pytest.mark.asyncio -async def test_hls_download_uses_container_extension_for_ffmpeg(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - manager = DownloadManager() - captured: dict[str, Path] = {} - - async def fake_download(url: str, dest: Path, headers: dict[str, str]) -> Path: - captured["dest"] = dest - dest.write_bytes(b"media") - return dest - - async def fake_update(_job_id: int, **_fields) -> None: - return None - - monkeypatch.setattr("backend.downloader.manager.ffmpeg_tools.download_hls_with_ffmpeg", fake_download) - monkeypatch.setattr(manager, "_update", fake_update) - - final = tmp_path / "Episode 01.mp4" - temporary = await manager._download_hls(1, "https://cdn.example/video.m3u8", final, {}) - - assert temporary == final.with_name("Episode 01.part.mp4") - assert captured["dest"].suffix == ".mp4" diff --git a/tests/test_media_proxy.py b/tests/test_media_proxy.py new file mode 100644 index 0000000..20bc132 --- /dev/null +++ b/tests/test_media_proxy.py @@ -0,0 +1,166 @@ +"""Media proxy: scoped sessions, SSRF protection, header isolation, playlist rewriting.""" + +from __future__ import annotations + +import socket + +import httpx +import pytest + +from backend.player import proxy, sessions +from backend.player.sessions import UnsafeUpstream, media_sessions, validate_upstream +from backend.providers import http as provider_http + +PUBLIC = {"cdn.example.com": "93.184.216.34", "other.example.net": "93.184.216.35", "rebind.example.com": "127.0.0.1"} + + +@pytest.fixture(autouse=True) +def fake_dns(monkeypatch): + sessions._dns_cache.clear() + + async def resolve(host, _port): + if host not in PUBLIC: + raise socket.gaierror("unknown host") + return [PUBLIC[host]] + + monkeypatch.setattr(sessions, "_resolve", resolve) + yield + sessions._dns_cache.clear() + + +@pytest.fixture +def upstream(monkeypatch): + """Route media-client traffic to an in-memory handler and record requests.""" + seen: list[httpx.Request] = [] + responses: dict[str, httpx.Response] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return responses.get(str(request.url), httpx.Response(404)) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr(provider_http.media_clients, "get", lambda _proxy: client) + return seen, responses + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/secret", + "http://localhost:8787/api/session", + "http://[::1]/", + "http://10.0.0.5/admin", + "http://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/", + "file:///etc/passwd", + "ftp://cdn.example.com/x", + "http://user:pass@cdn.example.com/x", + "http://rebind.example.com/", # public name resolving to loopback + "http://printer.local/", + ], +) +async def test_validate_upstream_rejects_local_targets(url): + with pytest.raises(UnsafeUpstream): + await validate_upstream(url) + + +async def test_validate_upstream_allows_public_hosts(): + await validate_upstream("https://cdn.example.com/video.m3u8") + await validate_upstream("https://93.184.216.34/video.mp4") + + +async def test_arbitrary_urls_cannot_be_proxied(client): + legacy = await client.get("/api/proxy/stream", params={"url": "http://127.0.0.1:8787/api/session"}) + assert legacy.status_code in (404, 405) + + session = media_sessions.create({}, None, ttl=60) + unknown = await client.get(f"/api/media/{session.id}/not-issued/x.ts") + assert unknown.status_code == 404 + expired = await client.get("/api/media/does-not-exist/token/x.ts") + assert expired.status_code == 410 + assert expired.json()["detail"]["code"] == "stream_expired" + + +async def test_playlist_is_rewritten_to_session_tokens(client, upstream): + _seen, responses = upstream + responses["https://cdn.example.com/show/master.m3u8"] = httpx.Response( + 200, + headers={"content-type": "application/vnd.apple.mpegurl"}, + text='#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="keys/k.bin"\n#EXTINF:5,\nseg-1.ts\n#EXTINF:5,\nhttps://other.example.net/seg-2.ts\n', + ) + session = media_sessions.create({"User-Agent": "UA"}, None, ttl=60) + response = await client.get(session.url_for("https://cdn.example.com/show/master.m3u8")) + assert response.status_code == 200 + body = response.text + assert "cdn.example.com" not in body and "other.example.net" not in body + issued = set(session.entries.values()) + assert { + "https://cdn.example.com/show/keys/k.bin", + "https://cdn.example.com/show/seg-1.ts", + "https://other.example.net/seg-2.ts", + } <= issued + assert f"/api/media/{session.id}/" in body and "seg-1.ts" in body + + +async def test_segments_stream_with_range_and_only_session_headers(client, upstream): + seen, responses = upstream + responses["https://cdn.example.com/seg.ts"] = httpx.Response( + 206, headers={"content-type": "video/mp2t", "content-range": "bytes 0-3/10"}, stream=httpx.ByteStream(b"abcd") + ) + session = media_sessions.create({"User-Agent": "Provider-UA", "Referer": "https://player.example/"}, None, ttl=60) + other = media_sessions.create({"Referer": "https://secret.example/", "Cookie": "sid=1"}, None, ttl=60) + other.url_for("https://cdn.example.com/seg.ts") + + response = await client.get( + session.url_for("https://cdn.example.com/seg.ts"), + headers={"Range": "bytes=0-3", "Cookie": "browser=secret"}, + ) + assert response.status_code == 206 + assert response.content == b"abcd" + sent = seen[-1].headers + assert sent["range"] == "bytes=0-3" + assert sent["referer"] == "https://player.example/" + assert sent["user-agent"] == "Provider-UA" + assert "cookie" not in sent # neither the browser's nor another session's cookies + + +async def test_redirect_to_private_network_is_blocked(client, upstream): + _seen, responses = upstream + responses["https://cdn.example.com/redirect.mp4"] = httpx.Response(302, headers={"location": "http://127.0.0.1:8787/api/session"}) + session = media_sessions.create({}, None, ttl=60) + response = await client.get(session.url_for("https://cdn.example.com/redirect.mp4")) + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "blocked_upstream" + + +async def test_expired_upstream_link_is_reported_for_re_resolution(client, upstream): + _seen, responses = upstream + responses["https://cdn.example.com/old.m3u8"] = httpx.Response(403) + session = media_sessions.create({}, None, ttl=60) + response = await client.get(session.url_for("https://cdn.example.com/old.m3u8")) + assert response.status_code == 502 + assert response.json()["detail"]["code"] == "stream_expired" + + +def test_sessions_expire_and_are_capped(monkeypatch): + store = sessions.MediaSessionStore() + session = store.create({}, None, ttl=60) + assert store.get(session.id) is session + session.last_used -= 120 + assert store.get(session.id) is None + monkeypatch.setattr(sessions, "MAX_SESSIONS", 3) + created = [store.create({}, None, ttl=60) for _ in range(5)] + assert len(store) == 3 and store.get(created[0].id) is None + + +def test_media_filename_keeps_extension_and_is_safe(): + assert sessions.media_filename("https://x/a/b/seg-01.ts?token=1") == "seg-01.ts" + assert sessions.media_filename("https://x/?expires=1") == "media" + assert "/" not in sessions.media_filename("https://x/..%2F..%2Fetc%2Fpasswd") + + +def test_rewrite_keeps_comments_and_blank_lines(): + session = media_sessions.create({}, None, ttl=60) + out = proxy.rewrite_playlist("#EXTM3U\n\n#EXT-X-ENDLIST\n", "https://cdn.example.com/a.m3u8", session) + assert out == "#EXTM3U\n\n#EXT-X-ENDLIST\n" diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..6e8b3a8 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,174 @@ +"""Schema migration from AnimeBox 1.x (user_version 0) databases.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from conftest import write_media + +from backend.database import migrations + +V0_SCHEMA = """ +CREATE TABLE anime_titles (id INTEGER NOT NULL, external_id VARCHAR(512) NOT NULL, source VARCHAR(64) NOT NULL, + title VARCHAR(512) NOT NULL, poster VARCHAR(1024), description TEXT, year INTEGER, folder_path VARCHAR(1024), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id)); +CREATE UNIQUE INDEX ix_anime_titles_external_id ON anime_titles (external_id); +CREATE TABLE library_episodes (id INTEGER NOT NULL, anime_external_id VARCHAR(512) NOT NULL, season INTEGER NOT NULL, + episode INTEGER NOT NULL, title VARCHAR(512), voiceover VARCHAR(256), file_path VARCHAR(1024) NOT NULL, + file_size INTEGER, downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, progress_seconds FLOAT NOT NULL, + duration_seconds FLOAT, completed BOOLEAN NOT NULL, PRIMARY KEY (id)); +CREATE TABLE download_jobs (id INTEGER NOT NULL, anime_external_id VARCHAR(512) NOT NULL, anime_title VARCHAR(512) NOT NULL, + source VARCHAR(64) NOT NULL, season INTEGER NOT NULL, episode INTEGER NOT NULL, episode_title VARCHAR(512), + voiceover VARCHAR(256), source_url VARCHAR(2048), status VARCHAR(32) NOT NULL, progress FLOAT NOT NULL, error TEXT, + file_path VARCHAR(1024), steam_deck BOOLEAN NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY (id)); +CREATE TABLE watch_history (id INTEGER NOT NULL, anime_external_id VARCHAR(512) NOT NULL, anime_title VARCHAR(512) NOT NULL, + poster VARCHAR(1024), season INTEGER NOT NULL, episode INTEGER NOT NULL, progress_seconds FLOAT NOT NULL, + duration_seconds FLOAT, local_episode_id INTEGER, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY (id)); +CREATE TABLE app_settings ("key" VARCHAR(128) NOT NULL, value TEXT NOT NULL, PRIMARY KEY ("key")); +""" + + +@pytest.fixture +def legacy_db(tmp_path: Path) -> tuple[Path, migrations.MigrationContext]: + app_root = tmp_path / "app" + library_root = app_root / "library" + write_media(library_root / "Show" / "Season 1" / "Episode 01.mp4") + (library_root / "Browsed Only").mkdir(parents=True) + db = app_root / "data" / "animebox.db" + db.parent.mkdir(parents=True) + conn = sqlite3.connect(db) + conn.executescript(V0_SCHEMA) + conn.executemany( + "INSERT INTO anime_titles (id, external_id, source, title, folder_path, updated_at) VALUES (?,?,?,?,?,?)", + [ + (1, "animego:%2Fanime%2Fshow-1", "animego", "Show", "library\\Show", "2026-01-01"), + (2, "animego:/anime/show-1", "animego", "Show (newer)", "library\\Show", "2026-02-01"), + (3, "anilibria:id%3A9", "anilibria", "Browsed Only", "library\\Browsed Only", "2026-01-01"), + ], + ) + conn.executemany( + "INSERT INTO library_episodes (id, anime_external_id, season, episode, file_path, progress_seconds, completed, downloaded_at)" + " VALUES (?,?,?,?,?,?,?,?)", + [ + (1, "animego:%2Fanime%2Fshow-1", 1, 1, "library\\Show\\Season 1\\Episode 01.mp4", 100, 0, "2026-01-01"), + (2, "animego:/anime/show-1", 1, 1, "library\\Show\\Season 1\\Episode 01.mkv", 300, 0, "2026-03-01"), + ], + ) + conn.executemany( + "INSERT INTO watch_history (id, anime_external_id, anime_title, season, episode, progress_seconds, local_episode_id, updated_at)" + " VALUES (?,?,?,?,?,?,?,?)", + [ + (1, "animego:%2Fanime%2Fshow-1", "Show", 1, 1, 50, 2, "2026-01-01"), + (2, "animego:/anime/show-1", "Show", 1, 1, 400, None, "2026-04-01"), + ], + ) + conn.execute( + "INSERT INTO download_jobs (anime_external_id, anime_title, source, season, episode, status, progress, steam_deck)" + " VALUES ('animego:/anime/show-1', 'Show', 'animego', 1, 2, 'downloading', 40, 0)" + ) + conn.execute("INSERT INTO app_settings VALUES ('default_quality', '720')") + conn.commit() + conn.close() + return db, migrations.MigrationContext(library_root=library_root, app_root=app_root) + + +def rows(db: Path, sql: str) -> list[tuple]: + conn = sqlite3.connect(db) + try: + return conn.execute(sql).fetchall() + finally: + conn.close() + + +def test_upgrade_merges_duplicates_and_preserves_user_data(legacy_db): + db, ctx = legacy_db + before, after, backup = migrations.upgrade(db, ctx) + + assert (before, after) == (0, migrations.LATEST_VERSION) + assert backup is not None and backup.is_file() + assert rows(backup, "SELECT count(*) FROM anime_titles") == [(3,)] # untouched copy + + assert rows(db, "SELECT external_id, title, folder_path FROM anime_titles ORDER BY id") == [ + ("animego:%2Fanime%2Fshow-1", "Show (newer)", "Show"), + ("anilibria:id%3A9", "Browsed Only", None), # browsing no longer means "in library" + ] + # The row whose file exists wins over the newer row pointing at a missing file; best progress kept. + assert rows(db, "SELECT id, file_path, progress_seconds FROM library_episodes") == [ + (1, "Show/Season 1/Episode 01.mp4", 300.0) + ] + assert rows(db, "SELECT anime_external_id, progress_seconds, local_episode_id FROM watch_history") == [ + ("animego:%2Fanime%2Fshow-1", 400.0, 1) + ] + assert rows(db, "SELECT anime_external_id, status, bytes_done, attempts FROM download_jobs") == [ + ("animego:%2Fanime%2Fshow-1", "downloading", 0, 0) + ] + assert rows(db, "SELECT * FROM app_settings") == [("default_quality", "720")] + assert rows(db, "PRAGMA user_version") == [(migrations.LATEST_VERSION,)] + + +def test_upgrade_is_repeatable(legacy_db): + db, ctx = legacy_db + migrations.upgrade(db, ctx) + snapshot = rows(db, "SELECT * FROM library_episodes") + assert migrations.upgrade(db, ctx) == (migrations.LATEST_VERSION, migrations.LATEST_VERSION, None) + assert rows(db, "SELECT * FROM library_episodes") == snapshot + + +def test_unique_index_prevents_duplicate_episode_rows(legacy_db): + db, ctx = legacy_db + migrations.upgrade(db, ctx) + conn = sqlite3.connect(db) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO library_episodes (anime_external_id, season, episode, file_path, progress_seconds, completed)" + " VALUES ('animego:%2Fanime%2Fshow-1', 1, 1, 'x.mp4', 0, 0)" + ) + conn.close() + + +def test_failed_migration_rolls_back_and_keeps_backup(legacy_db, monkeypatch): + db, ctx = legacy_db + original = rows(db, "SELECT * FROM anime_titles ORDER BY id") + + def explode(conn, _ctx): + conn.execute("UPDATE anime_titles SET title='broken'") + raise RuntimeError("boom") + + monkeypatch.setitem(migrations.MIGRATIONS, 1, explode) + with pytest.raises(RuntimeError): + migrations.upgrade(db, ctx) + assert rows(db, "SELECT * FROM anime_titles ORDER BY id") == original + assert rows(db, "PRAGMA user_version") == [(0,)] + assert list((db.parent / "backups").glob("*.db")) + + +def test_fresh_or_missing_database_is_left_for_create_all(tmp_path): + ctx = migrations.MigrationContext(library_root=tmp_path / "library") + assert migrations.upgrade(tmp_path / "missing.db", ctx)[2] is None + empty = tmp_path / "empty.db" + sqlite3.connect(empty).close() + assert migrations.upgrade(empty, ctx) == (0, 0, None) + assert not (tmp_path / "backups").exists() + + +def test_partial_legacy_database_gets_missing_tables(tmp_path): + db = tmp_path / "partial.db" + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE anime_titles (id INTEGER PRIMARY KEY, external_id VARCHAR(512) NOT NULL UNIQUE," + " source VARCHAR(64) NOT NULL, title VARCHAR(512) NOT NULL, poster TEXT, description TEXT, year INTEGER," + " folder_path TEXT, created_at DATETIME, updated_at DATETIME)" + ) + conn.execute("INSERT INTO anime_titles (external_id, source, title) VALUES ('animego:/anime/x-1', 'animego', 'X')") + conn.commit() + conn.close() + + migrations.upgrade(db, migrations.MigrationContext(library_root=tmp_path / "library")) + + tables = {row[0] for row in rows(db, "SELECT name FROM sqlite_master WHERE type='table'")} + assert set(migrations.CORE_TABLES) <= tables + assert rows(db, "SELECT external_id FROM anime_titles") == [("animego:%2Fanime%2Fx-1",)] diff --git a/tests/test_portable_main.py b/tests/test_portable_main.py index 010b5ba..c3749db 100644 --- a/tests/test_portable_main.py +++ b/tests/test_portable_main.py @@ -58,3 +58,28 @@ async def serve(self) -> None: assert captured["cwd"] == root assert captured["data_exists"] is True assert captured["library_exists"] is True + + +def test_file_logging_is_written_even_after_backend_configured_console(tmp_path: Path) -> None: + import logging + + from backend.main import configure_logging + + configure_logging() # backend import already installed console handlers in 1.x, silencing basicConfig + log_path = tmp_path / "animebox.log" + before = list(logging.getLogger().handlers) + portable_main.setup_file_logging(log_path) + try: + logging.getLogger("animebox.test").warning("proxy http://user:secret@proxy.example:8080 failed") + configure_logging() + logging.getLogger("animebox.test").warning("proxy http://user:secret@proxy.example:8080 failed") + for handler in logging.getLogger().handlers: + handler.flush() + text = log_path.read_text(encoding="utf-8") + assert "failed" in text + assert text.strip().splitlines()[-1].count("secret") == 0 # redaction applies to the file handler + finally: + for handler in list(logging.getLogger().handlers): + if handler not in before: + logging.getLogger().removeHandler(handler) + handler.close() diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 9ddacd8..45a4ab8 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -1,73 +1,273 @@ +"""Provider engine: isolation of failures, player fallback, exact cache recovery, health.""" + from __future__ import annotations import asyncio +from dataclasses import dataclass +from types import SimpleNamespace import pytest from backend.config import Settings -from backend.providers.registry import ProviderRegistry -from backend.schemas import SearchResult +from backend.providers import players +from backend.providers.errors import CacheExpired, NotFound, StreamUnavailable +from backend.providers.health import HealthTracker, SourceHealth, StageResult +from backend.providers.ids import canonical_id, decode_external_id, encode_external_id +from backend.providers.registry import AnicliProvider, ProviderRegistry -@pytest.mark.asyncio -async def test_search_returns_fast_results_and_cancels_timed_out_provider( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: +def make_registry(tmp_path, **overrides) -> ProviderRegistry: settings = Settings( _env_file=None, + data_path=tmp_path / "data", library_path=tmp_path / "library", database_url="sqlite+aiosqlite:///:memory:", - provider_search_timeout=0.02, + **overrides, ) - registry = ProviderRegistry(settings) + return ProviderRegistry(settings, health=HealthTracker(None)) + + +@dataclass +class Hit: + title: str + url: str + thumbnail: str = "" + + +class FakeExtractor: + def __init__(self, behaviour): + self.behaviour = behaviour + + async def a_search(self, query): + return await self.behaviour(query) + + +@pytest.fixture +def extractors(monkeypatch): + table: dict[str, object] = {} + monkeypatch.setattr(AnicliProvider, "extractor", lambda self: FakeExtractor(table[self.spec.id])) + return table + + +async def test_search_returns_fast_results_when_another_provider_hangs(tmp_path, extractors): + registry = make_registry(tmp_path, provider_search_timeout=0.05) cancelled = asyncio.Event() - async def fake_search_one(source: str, _query: str) -> list[SearchResult]: - if source == "blocked": - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - cancelled.set() - raise - return [ - SearchResult( - id=f"{source}:result", - title="K-On!", - source=source, - ) - ] - - monkeypatch.setattr(registry, "_search_one", fake_search_one) - - results = await asyncio.wait_for( - registry.search("k-on", sources=["fast", "blocked"]), - timeout=0.2, - ) + async def fast(_q): + return [Hit("K-On!", "/anime/k-on-1")] + + async def hang(_q): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise - assert [(item.source, item.title) for item in results] == [("fast", "K-On!")] + extractors.update(animego=fast, anilibria=hang) + response = await asyncio.wait_for(registry.search("k-on", sources=["animego", "anilibria"]), timeout=1) + + assert [(r.source, r.title) for r in response.results] == [("animego", "K-On!")] + status = {p.source: p for p in response.providers} + assert status["animego"].ok and status["animego"].count == 1 + assert not status["anilibria"].ok and status["anilibria"].timed_out assert cancelled.is_set() + assert registry.health.get("anilibria").stages["search"].ok is False -@pytest.mark.asyncio -async def test_search_keeps_results_when_another_provider_fails( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - settings = Settings( - _env_file=None, - library_path=tmp_path / "library", - database_url="sqlite+aiosqlite:///:memory:", - ) - registry = ProviderRegistry(settings) +async def test_search_keeps_results_when_another_provider_raises(tmp_path, extractors): + registry = make_registry(tmp_path) + + async def fast(_q): + return [Hit("K-On!", "/anime/k-on-1"), Hit("K-On!", "/anime/k-on-1")] + + async def broken(_q): + raise RuntimeError("HTTP 403 from provider") + + extractors.update(animego=fast, animevost=broken) + response = await registry.search("k-on", sources=["animego", "animevost"]) + assert len(response.results) == 1 # same id deduplicated + failed = next(p for p in response.providers if p.source == "animevost") + assert "403" in failed.error + + +async def test_unknown_sources_are_ignored_and_not_crashing(tmp_path, extractors): + registry = make_registry(tmp_path) + + async def fast(_q): + return [] + + extractors.update(animego=fast) + response = await registry.search("x", sources=["animego", "not-a-source"]) + assert [p.source for p in response.providers] == ["animego"] + + +async def test_cache_recovery_never_returns_a_lookalike_title(tmp_path, extractors): + registry = make_registry(tmp_path) + + async def lookalikes(_q): + return [Hit("Steins;Gate 0", "/anime/steins-gate-0-2"), Hit("Steins;Gate Movie", "/anime/steins-gate-movie-3")] + + extractors.update(anilibria=lookalikes) + external_id = encode_external_id("anilibria", "id:762") + registry.remember_title(external_id, "Steins;Gate") + with pytest.raises(CacheExpired): + await registry._search_object(external_id) + + +def _source(voiceover: str, label: str) -> players.PlayerSource: + return players.PlayerSource(voiceover=voiceover, url=f"https://{label}.example/{voiceover}", label=label, raw=label) + + +@pytest.fixture +def pipeline(tmp_path, monkeypatch): + registry = make_registry(tmp_path) + sources = [_source("AniLibria", "cvh"), _source("AniLibria", "kodik"), _source("SHIZA", "sibnet")] + behaviour: dict[str, object] = {} + calls: list[str] = [] + + async def fake_anime(_id): + return SimpleNamespace(title="Fixture") + + async def fake_sources(_id, _episode, refresh=False): + return sources + + async def fake_extract(raw, _proxy): + calls.append(raw) + result = behaviour.get(raw, []) + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(registry, "_anime", fake_anime) + monkeypatch.setattr(registry, "_player_sources", fake_sources) + monkeypatch.setattr(players, "extract_videos", fake_extract) + monkeypatch.setattr(players, "player_health", players.PlayerHealth()) + return registry, behaviour, calls + + +def video(kind: str, quality: int, url: str = "https://cdn.example/v"): + return SimpleNamespace(type=kind, quality=quality, url=url, headers={}) + + +async def test_stream_falls_back_to_next_player_of_same_voiceover(pipeline): + registry, behaviour, calls = pipeline + behaviour["kodik"] = [] # a broken player returns nothing (AnimeGO cdn-iframe did this in 1.x) + behaviour["cvh"] = [video("m3u8", 720), video("m3u8", 480)] + + stream = await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="AniLibria", quality=1080) + assert calls == ["kodik", "cvh"] + assert (stream.voiceover, stream.player, stream.video.quality) == ("AniLibria", "cvh", 720) + assert stream.qualities == [720, 480] + assert stream.video.headers["User-Agent"] # consistent client identity is always attached + + calls.clear() # the failed player is demoted for the next request + await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="AniLibria") + assert calls == ["cvh"] + + +async def test_explicit_voiceover_never_silently_switches(pipeline): + registry, behaviour, _calls = pipeline + behaviour["sibnet"] = RuntimeError("timeout") + behaviour["kodik"] = [video("m3u8", 720)] + with pytest.raises(StreamUnavailable) as info: + await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="SHIZA") + assert info.value.attempts and all(a["voiceover"] == "SHIZA" for a in info.value.attempts) + + +async def test_unknown_voiceover_is_not_found(pipeline): + registry, _behaviour, _calls = pipeline + with pytest.raises(NotFound): + await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="Does Not Exist") + + +async def test_auto_voiceover_uses_preferences_then_falls_back(pipeline): + registry, behaviour, _calls = pipeline + registry.settings.preferred_voiceovers = "SHIZA,AniLibria" + behaviour["sibnet"] = RuntimeError("dead") + behaviour["kodik"] = [video("m3u8", 720)] + stream = await registry.resolve_stream("animego:%2Fanime%2Fx", 1) + assert stream.voiceover == "AniLibria" + + +async def test_dash_only_player_is_not_offered_for_browser_playback(pipeline): + registry, behaviour, _calls = pipeline + behaviour["cvh"] = [video("mpd", 1080)] + behaviour["kodik"] = RuntimeError("down") + with pytest.raises(StreamUnavailable): + await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="AniLibria") + download = await registry.resolve_stream("animego:%2Fanime%2Fx", 1, voiceover="AniLibria", purpose="download") + assert download.video.type == "mpd" and download.browser_playable is False + + +def test_player_ranking_demotes_recent_failures(): + health = players.PlayerHealth() + assert health.rank("kodik") < health.rank("cvh") + health.failure("kodik") + assert health.rank("kodik") > health.rank("cvh") + health.success("kodik") + assert health.rank("kodik") == 0 + + +def test_pick_video_prefers_hls_and_closest_lower_quality(): + videos = [video("mpd", 1080), video("m3u8", 1080), video("mp4", 720), video("m3u8", 360)] + assert players.pick_video(videos, 1080, players.BROWSER_TYPES).type == "m3u8" + assert players.pick_video(videos, 720, players.BROWSER_TYPES).quality == 720 + assert players.pick_video(videos, 240, players.BROWSER_TYPES).quality == 360 # lowest above when nothing below + assert players.pick_video([video("mpd", 1080)], 1080, players.BROWSER_TYPES) is None + + +@pytest.mark.parametrize( + ("url", "label"), + [ + ("https://animego.me/cdn-iframe/9253/1/1?dubbing=AnilibriaTV", "cvh"), + ("https://kodikplayer.com/seria/1/abc/720p", "kodik"), + ("https://aniboom.one/embed/x?episode=1", "aniboom"), + ("https://video.sibnet.ru/shell.php?videoid=1", "sibnet"), + ("https://player.example.org/e/1", "player.example.org"), + ], +) +def test_player_labels(url, label): + assert players.player_label(url) == label + + +def test_voiceover_normalisation(): + assert players.normalize_voiceover("Озвучка AniLibria") == "AniLibria" + assert players.voiceover_key("СВ-Дубль") == players.voiceover_key("св дубль") + assert players.voiceover_kind("Субтитры") == "subtitles" + assert players.voiceover_kind("AniDUB") == "dub" + + +def test_health_status_requires_every_stage(): + health = SourceHealth() + assert health.status == "unknown" + health.stages["search"] = StageResult(ok=True, at=0) + assert health.status == "partial" + for stage in ("details", "episodes", "voiceovers", "stream"): + health.stages[stage] = StageResult(ok=True, at=0) + assert health.status == "ok" + health.stages["stream"] = StageResult(ok=False, at=0, error="403") + assert health.status == "degraded" + health.stages["search"] = StageResult(ok=False, at=0) + assert health.status == "down" + + +def test_health_persists(tmp_path): + path = tmp_path / "health.json" + HealthTracker(path).record("animego", "search", True, 120) + assert HealthTracker(path).get("animego").stages["search"].latency_ms == 120 + - async def fake_search_one(source: str, _query: str) -> list[SearchResult]: - if source == "broken": - raise RuntimeError("provider unavailable") - return [SearchResult(id=f"{source}:result", title="K-On!", source=source)] +def test_ids_are_canonical(): + assert canonical_id("animego:/anime/x-1") == canonical_id("animego:%2Fanime%2Fx-1") == "animego:%2Fanime%2Fx-1" + assert decode_external_id("anilibria:id%3A762") == ("anilibria", "id:762") + with pytest.raises(ValueError): + decode_external_id("no-colon") + with pytest.raises(ValueError): + decode_external_id("../etc:passwd") - monkeypatch.setattr(registry, "_search_one", fake_search_one) - results = await registry.search("k-on", sources=["fast", "broken"]) +def test_excluded_and_unknown_modules_are_not_exposed(): + from backend.providers.catalog import EXCLUDED_MODULES, SOURCE_BY_ID - assert [(item.source, item.title) for item in results] == [("fast", "K-On!")] + assert "hdrezka" in EXCLUDED_MODULES and "hdrezka" not in SOURCE_BY_ID + assert SOURCE_BY_ID["animego"].stable and SOURCE_BY_ID["animego"].enabled_by_default From bd4a66556cd332cd34722e6d2380e98066c8a903 Mon Sep 17 00:00:00 2001 From: endercodezz Date: Sun, 11 Oct 2026 21:27:08 +0300 Subject: [PATCH 2/5] Frontend 2.0: Neon Cinema redesign and new player - New design system with bundled Unbounded/Manrope fonts (offline), shared UI components, app shell with sidebar, global search and mobile navigation. - Redesigned Home, Search, Title, Library, Downloads and Settings pages with loading, empty and error states; episode grid with progress, offline and queue markers. - Player: custom controls, keyboard shortcuts, quality/speed/voiceover/audio/subtitle menus, progress save and restore, stream recovery, next-episode countdown, episode drawer. - Route ids are canonicalised to match the backend; player chunk is lazy-loaded. - Vitest unit tests; check script runs type-check, lint and tests. --- frontend/index.html | 9 +- frontend/package-lock.json | 1182 ++++++++++++++++++- frontend/package.json | 16 +- frontend/public/favicon.svg | 10 +- frontend/src/App.tsx | 58 +- frontend/src/api/client.ts | 218 ++-- frontend/src/api/types.ts | 136 ++- frontend/src/components/AppShell.tsx | 257 ++++ frontend/src/components/ContinueCard.tsx | 45 + frontend/src/components/EpisodeGrid.tsx | 104 ++ frontend/src/components/PosterCard.tsx | 86 ++ frontend/src/components/components.test.tsx | 69 ++ frontend/src/components/icons.tsx | 176 +++ frontend/src/components/toast.tsx | 63 + frontend/src/components/ui.tsx | 312 +++++ frontend/src/index.css | 197 ++-- frontend/src/lib/cx.ts | 3 + frontend/src/lib/format.ts | 60 + frontend/src/lib/ids.ts | 19 + frontend/src/lib/lib.test.ts | 54 + frontend/src/lib/prefs.ts | 53 + frontend/src/lib/routes.ts | 31 + frontend/src/lib/useResource.ts | 62 + frontend/src/main.tsx | 3 + frontend/src/pages/AnimePage.tsx | 659 ++++++----- frontend/src/pages/DownloadsPage.tsx | 237 +++- frontend/src/pages/HomePage.tsx | 172 +++ frontend/src/pages/LibraryPage.tsx | 218 +++- frontend/src/pages/PlayerPage.tsx | 889 +++++++++++--- frontend/src/pages/SearchPage.tsx | 268 ++--- frontend/src/pages/SettingsPage.tsx | 449 ++++++- frontend/src/pages/player/Menu.tsx | 64 + frontend/src/pages/player/useSource.ts | 240 ++++ frontend/vite.config.ts | 9 + 34 files changed, 5514 insertions(+), 914 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/ContinueCard.tsx create mode 100644 frontend/src/components/EpisodeGrid.tsx create mode 100644 frontend/src/components/PosterCard.tsx create mode 100644 frontend/src/components/components.test.tsx create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/toast.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/lib/cx.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/ids.ts create mode 100644 frontend/src/lib/lib.test.ts create mode 100644 frontend/src/lib/prefs.ts create mode 100644 frontend/src/lib/routes.ts create mode 100644 frontend/src/lib/useResource.ts create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/player/Menu.tsx create mode 100644 frontend/src/pages/player/useSource.ts diff --git a/frontend/index.html b/frontend/index.html index c0981af..7408afe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,10 +3,11 @@ - - - - AnimeBox — локальная аниме-библиотека + + + + + AnimeBox
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f10ef0..a258cf3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/manrope": "^5.3.0", + "@fontsource-variable/unbounded": "^5.3.0", "hls.js": "^1.6.17", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -15,14 +17,284 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", + "jsdom": "^30.1.2", "oxlint": "^1.75.0", "tailwindcss": "^4.3.3", "typescript": "~6.0.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^5.0.3" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/unbounded": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/unbounded/-/unbounded-5.3.0.tgz", + "integrity": "sha512-ea565Ncn4r7gPdBEHcmaF31V11hDfkL7b/JCklLobQbMfbu2ZZQrVWNgdeEFWFLqNh36yhieZWOgxdAnMTzrLg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, "node_modules/@jridgewell/gen-mapping": { @@ -58,9 +330,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1252,6 +1524,124 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1308,64 +1698,277 @@ } } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, + "peer": true, "engines": { - "node": ">=10.13.0" + "node": ">=8" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=10" }, - "peerDependenciesMeta": { - "picomatch": { + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { "optional": true } } @@ -1398,6 +2001,36 @@ "integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==", "license": "Apache-2.0" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1408,6 +2041,54 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1681,6 +2362,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1691,6 +2393,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1710,6 +2429,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", @@ -1759,6 +2492,19 @@ } } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1767,9 +2513,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1808,6 +2554,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1829,6 +2601,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -1867,6 +2647,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", @@ -1900,6 +2704,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1913,15 +2730,35 @@ "license": "MIT" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1943,6 +2780,26 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1960,6 +2817,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1974,6 +2877,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2058,6 +2971,177 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 4a5fb19..95704b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,15 +1,19 @@ { - "name": "frontend", + "name": "animebox-frontend", "private": true, - "version": "0.0.0", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "check": "tsc -b && oxlint && vitest run" }, "dependencies": { + "@fontsource-variable/manrope": "^5.3.0", + "@fontsource-variable/unbounded": "^5.3.0", "hls.js": "^1.6.17", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -17,13 +21,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", + "jsdom": "^30.1.2", "oxlint": "^1.75.0", "tailwindcss": "^4.3.3", "typescript": "~6.0.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^5.0.3" } } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 556d45f..03bdd36 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,7 +1,5 @@ - - - - - - + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a7d5b1..7625bac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,52 @@ -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' -import { Layout } from './components/Layout' +import { lazy, Suspense } from 'react' +import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { AppShell } from './components/AppShell' +import { ToastProvider } from './components/toast' +import { ButtonLink, EmptyState, Spinner } from './components/ui' import { AnimePage } from './pages/AnimePage' import { DownloadsPage } from './pages/DownloadsPage' +import { HomePage } from './pages/HomePage' import { LibraryPage } from './pages/LibraryPage' -import { PlayerPage } from './pages/PlayerPage' import { SearchPage } from './pages/SearchPage' import { SettingsPage } from './pages/SettingsPage' +// hls.js is only needed for playback; keep it out of the initial bundle. +const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))) + +function NotFound() { + return ( +
+ На главную}> + Такой страницы в AnimeBox нет. + +
+ ) +} + export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + +
}> + + + } + /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0e7b797..64aa979 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,110 +1,164 @@ import type { AnimeDetail, AppSettings, + Diagnostics, + DownloadCreateResponse, DownloadJob, + HomeFeed, LibraryAnime, LibraryEpisode, - SearchResult, - SourceInfo, - StreamResolveResponse, + LibraryStats, + ProviderInfo, + RescanResult, + SearchResponse, + StreamInfo, VoiceoverOption, WatchHistory, } from './types' -async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(path, { - ...init, - headers: { - 'Content-Type': 'application/json', - ...(init?.headers || {}), - }, - }) - if (!res.ok) { - let detail = res.statusText - try { - const body = await res.json() - detail = body.detail || body.message || JSON.stringify(body) - } catch { - /* ignore */ +/** Error raised for non-2xx API responses; carries the backend's structured detail. */ +export class ApiError extends Error { + status: number + code: string + stage: string | null + source: string | null + + constructor(status: number, message: string, code = 'error', stage: string | null = null, source: string | null = null) { + super(message) + this.status = status + this.code = code + this.stage = stage + this.source = source + } +} + +type Detail = { code?: string; message?: string; stage?: string | null; source?: string | null } + +async function parseError(res: Response): Promise { + let message = res.statusText || `HTTP ${res.status}` + let code = 'error' + let stage: string | null = null + let source: string | null = null + try { + const body = await res.json() + const detail: unknown = body?.detail ?? body + if (typeof detail === 'string') message = detail + else if (Array.isArray(detail)) message = detail.map((d) => d?.msg).filter(Boolean).join('; ') || message + else if (detail && typeof detail === 'object') { + const d = detail as Detail + message = d.message || message + code = d.code || code + stage = d.stage ?? null + source = d.source ?? null } - throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)) + } catch { + /* non-JSON error body */ } + return new ApiError(res.status, message, code, stage, source) +} + +async function request(path: string, init?: RequestInit): Promise { + let res: Response + try { + res = await fetch(path, { + ...init, + headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + }) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') throw err + throw new ApiError(0, 'AnimeBox не отвечает. Проверьте, что приложение запущено.', 'offline') + } + if (!res.ok) throw await parseError(res) if (res.status === 204) return undefined as T return res.json() as Promise } +const q = (params: Record) => { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== null && value !== undefined && value !== '') search.set(key, String(value)) + return search.toString() +} + +const json = (body: unknown): RequestInit => ({ body: JSON.stringify(body) }) + +export type ProgressPayload = { + anime_id?: string | null + anime_title?: string | null + poster?: string | null + season?: number + episode: number + voiceover?: string | null + progress_seconds: number + duration_seconds?: number | null + local_episode_id?: number | null +} + export const api = { - health: () => request<{ status: string }>('/api/health'), - session: () => request<{ shutdown_token: string; shutdown_enabled: boolean }>('/api/session'), + health: () => request<{ status: string; version: string }>('/api/health'), + session: () => request<{ shutdown_token: string; shutdown_enabled: boolean; version: string }>('/api/session'), shutdown: async () => { - const session = await request<{ shutdown_token: string; shutdown_enabled: boolean }>('/api/session') - return request<{ message: string }>('/api/shutdown', { - method: 'POST', - headers: { 'X-AnimeBox-Token': session.shutdown_token }, - }) + const session = await api.session() + return request<{ message: string }>('/api/shutdown', { method: 'POST', headers: { 'X-AnimeBox-Token': session.shutdown_token } }) }, - sources: () => request('/api/sources'), - search: (q: string, source?: string) => { - const params = new URLSearchParams({ q }) - if (source) params.set('source', source) - return request(`/api/search?${params}`) - }, - anime: (id: string, voiceovers = false) => - request(`/api/anime/${encodeURIComponent(id)}?voiceovers=${voiceovers}`), - voiceovers: (id: string, episode: number) => - request( - `/api/anime/${encodeURIComponent(id)}/episodes/${episode}/voiceovers`, - ), - resolveStream: (body: { - anime_id: string - episode: number - voiceover?: string | null - quality?: number | null - }) => - request('/api/stream/resolve', { - method: 'POST', - body: JSON.stringify(body), - }), + + providers: () => request('/api/providers'), + checkProvider: (id: string) => request(`/api/providers/${encodeURIComponent(id)}/check`, { method: 'POST' }), + search: (query: string, source?: string, signal?: AbortSignal) => + request(`/api/search?${q({ q: query, source })}`, { signal }), + anime: (id: string, signal?: AbortSignal) => request(`/api/anime?${q({ id })}`, { signal }), + voiceovers: (id: string, episode: number, signal?: AbortSignal) => + request(`/api/anime/voiceovers?${q({ id, episode })}`, { signal }), + resolveStream: ( + body: { anime_id: string; episode: number; voiceover?: string | null; quality?: number | null; player?: string | null; refresh?: boolean }, + signal?: AbortSignal, + ) => request('/api/stream/resolve', { method: 'POST', ...json(body), signal }), + createDownloads: (body: { anime_id: string season?: number episode?: number | null episodes?: number[] | null voiceover?: string | null - steam_deck?: boolean - }) => - request('/api/downloads', { - method: 'POST', - body: JSON.stringify(body), - }), - downloads: () => request('/api/downloads'), - cancelDownload: (id: number) => - request<{ message: string }>(`/api/downloads/${id}/cancel`, { method: 'POST' }), - retryDownload: (id: number) => - request<{ message: string }>(`/api/downloads/${id}/retry`, { method: 'POST' }), - library: () => request('/api/library'), - libraryEpisodes: (id: string) => - request(`/api/library/${encodeURIComponent(id)}/episodes`), - libraryFileUrl: (episodeId: number) => `/api/library/file/${episodeId}`, + quality?: number | null + replace?: boolean + }) => request('/api/downloads', { method: 'POST', ...json(body) }), + downloads: (status: 'all' | 'active' | 'finished' = 'all', signal?: AbortSignal) => + request(`/api/downloads?${q({ status })}`, { signal }), + downloadAction: (id: number, action: 'cancel' | 'retry' | 'pause' | 'resume') => + request<{ message: string }>(`/api/downloads/${id}/${action}`, { method: 'POST' }), + removeDownload: (id: number) => request<{ message: string }>(`/api/downloads/${id}`, { method: 'DELETE' }), + clearDownloads: () => request<{ message: string }>('/api/downloads/clear', { method: 'POST' }), + + library: (signal?: AbortSignal) => request('/api/library', { signal }), + libraryStats: () => request('/api/library/stats'), + libraryEpisodes: (id: string, signal?: AbortSignal) => request(`/api/library/episodes?${q({ id })}`, { signal }), + deleteLibraryAnime: (id: string) => request<{ message: string }>(`/api/library/anime?${q({ id })}`, { method: 'DELETE' }), + deleteLibraryEpisode: (episodeId: number) => request<{ message: string }>(`/api/library/episodes/${episodeId}`, { method: 'DELETE' }), + rescanLibrary: () => request('/api/library/rescan', { method: 'POST' }), + + home: (signal?: AbortSignal) => request('/api/home', { signal }), history: () => request('/api/history'), - saveProgress: (body: { - anime_id?: string | null - anime_title?: string | null - poster?: string | null - season?: number - episode: number - progress_seconds: number - duration_seconds?: number | null - local_episode_id?: number | null - }) => - request('/api/progress', { - method: 'POST', - body: JSON.stringify(body), - }), + animeHistory: (id: string, signal?: AbortSignal) => request(`/api/history/anime?${q({ id })}`, { signal }), + deleteHistory: (entryId: number) => request<{ message: string }>(`/api/history/${entryId}`, { method: 'DELETE' }), + clearHistory: () => request<{ message: string }>('/api/history', { method: 'DELETE' }), + saveProgress: (body: ProgressPayload) => request('/api/progress', { method: 'POST', ...json(body) }), + /** Fire-and-forget save that survives page unload. */ + beaconProgress: (body: ProgressPayload) => { + const payload = JSON.stringify(body) + if (navigator.sendBeacon?.('/api/progress', new Blob([payload], { type: 'application/json' }))) return + void fetch('/api/progress', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {}) + }, + settings: () => request('/api/settings'), - updateSettings: (body: Partial) => - request('/api/settings', { - method: 'PUT', - body: JSON.stringify(body), - }), + updateSettings: (body: Partial) => request('/api/settings', { method: 'PUT', ...json(body) }), + diagnostics: () => request('/api/diagnostics'), +} + +export function errorMessage(err: unknown, fallback = 'Что-то пошло не так'): string { + if (err instanceof ApiError || err instanceof Error) return err.message || fallback + return fallback +} + +export function isAbort(err: unknown): boolean { + return err instanceof DOMException && err.name === 'AbortError' } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 15444da..1e15085 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -5,21 +5,35 @@ export type SearchResult = { description: string | null year: number | null source: string + source_label: string | null } +export type ProviderSearchStatus = { + source: string + label: string + ok: boolean + count: number + latency_ms: number | null + error: string | null + timed_out: boolean +} + +export type SearchResponse = { + query: string + results: SearchResult[] + providers: ProviderSearchStatus[] +} + +export type VoiceoverKind = 'dub' | 'subtitles' | 'original' + export type VoiceoverOption = { title: string - url: string - player_host: string | null + kind: VoiceoverKind + players: string[] index: number } -export type EpisodeInfo = { - title: string - ordinal: number - season: number - voiceovers: VoiceoverOption[] -} +export type EpisodeInfo = { title: string; ordinal: number; season: number } export type AnimeDetail = { id: string @@ -28,10 +42,34 @@ export type AnimeDetail = { description: string | null year: number | null source: string + source_label: string | null seasons: number[] episodes: EpisodeInfo[] } +export type StreamInfo = { + anime_title: string + episode: number + voiceover: string + player: string + type: string + quality: number + qualities: number[] + url: string + browser_playable: boolean + expires_at: number +} + +export type DownloadStatus = + | 'queued' + | 'resolving' + | 'downloading' + | 'processing' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled' + export type DownloadJob = { id: number anime_external_id: string @@ -41,13 +79,27 @@ export type DownloadJob = { episode: number episode_title: string | null voiceover: string | null - status: string + status: DownloadStatus progress: number error: string | null + warning: string | null file_path: string | null steam_deck: boolean + quality: number | null + player_host: string | null + bytes_done: number + bytes_total: number | null + speed_bps: number | null + attempts: number + resumable: boolean created_at: string | null updated_at: string | null + finished_at: string | null +} + +export type DownloadCreateResponse = { + jobs: DownloadJob[] + skipped: { episode: number; reason: 'already_downloaded' | 'already_queued' }[] } export type LibraryAnime = { @@ -58,6 +110,10 @@ export type LibraryAnime = { year: number | null source: string episode_count: number + missing_count: number + watched_count: number + size_bytes: number + last_downloaded_at: string | null folder_path: string | null } @@ -70,10 +126,22 @@ export type LibraryEpisode = { voiceover: string | null file_path: string file_size: number | null + quality: number | null progress_seconds: number duration_seconds: number | null completed: boolean downloaded_at: string | null + available: boolean + stream_url: string | null +} + +export type LibraryStats = { + titles: number + episodes: number + size_bytes: number + library_path: string + disk_total: number + disk_free: number } export type WatchHistory = { @@ -83,10 +151,18 @@ export type WatchHistory = { poster: string | null season: number episode: number + voiceover: string | null progress_seconds: number duration_seconds: number | null local_episode_id: number | null updated_at: string | null + completed: boolean +} + +export type HomeFeed = { + continue_watching: WatchHistory[] + recently_watched: WatchHistory[] + recently_downloaded: LibraryAnime[] } export type AppSettings = { @@ -96,23 +172,39 @@ export type AppSettings = { steam_deck_crf: number steam_deck_height: number default_source: string + enabled_sources: string[] http_proxy: string | null + max_concurrent_downloads: number + autoplay_next: boolean + remember_volume: boolean } -export type StreamResolveResponse = { - anime_title: string - episode: number - voiceover: string - video: { - type: string - quality: number - url: string - headers: Record - } - proxy_url: string | null -} +export type ProviderStatus = 'ok' | 'degraded' | 'down' | 'partial' | 'unknown' -export type SourceInfo = { +export type StageResult = { ok: boolean; at: number; latency_ms: number | null; error: string | null } + +export type ProviderInfo = { id: string label: string + stable: boolean + enabled: boolean + is_default: boolean + note: string + status: ProviderStatus + stages: Partial> + last_check_at: number | null +} + +export type Diagnostics = { + version: string + python: string + platform: string + frozen: boolean + ffmpeg: { path: string | null; version: string | null } + paths: { library: string; data: string; database: string } + disk: { total: number; used: number; free: number } + network: { proxy: string | null } + runtime: { media_sessions: number; running_downloads: number; max_concurrent_downloads: number } } + +export type RescanResult = { removed_missing: number; imported: number; removed_empty_dirs: number } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..ed888b7 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,257 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { NavLink, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { api, errorMessage } from '../api/client' +import { routes } from '../lib/routes' +import { useInterval } from '../lib/useResource' +import { Icon, type IconName } from './icons' +import { useToast } from './toast' +import { ConfirmDialog } from './ui' +import { cx } from '../lib/cx' + +const NAV: { to: string; label: string; icon: IconName; end?: boolean }[] = [ + { to: '/', label: 'Главная', icon: 'home', end: true }, + { to: '/search', label: 'Поиск', icon: 'search' }, + { to: '/library', label: 'Библиотека', icon: 'library' }, + { to: '/downloads', label: 'Загрузки', icon: 'download' }, + { to: '/settings', label: 'Настройки', icon: 'settings' }, +] + +function Logo({ compact }: { compact?: boolean }) { + return ( + + + A + + + {!compact && ( + + AnimeBox + + )} + + ) +} + +function useActiveDownloads(): number { + const [count, setCount] = useState(0) + const refresh = () => + api + .downloads('active') + .then((jobs) => setCount(jobs.filter((j) => j.status !== 'paused').length)) + .catch(() => {}) + useEffect(() => { + void refresh() + }, []) + useInterval(refresh, 5000) + return count +} + +function GlobalSearch() { + const navigate = useNavigate() + const location = useLocation() + const [params] = useSearchParams() + const [value, setValue] = useState('') + const input = useRef(null) + + useEffect(() => { + if (location.pathname === '/search') setValue(params.get('q') || '') + }, [location.pathname, params]) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + const typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + if ((event.key === '/' && !typing) || (event.key.toLowerCase() === 'k' && (event.ctrlKey || event.metaKey))) { + event.preventDefault() + input.current?.focus() + input.current?.select() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + function submit(event: FormEvent) { + event.preventDefault() + const query = value.trim() + if (!query) return + navigate(routes.search(query, params.get('source') || undefined)) + input.current?.blur() + } + + return ( +
+ + setValue(e.target.value)} + placeholder="Найти аниме" + aria-label="Найти аниме" + maxLength={200} + className="h-10 w-full rounded-xl border border-line bg-white/[0.04] pl-10 pr-12 text-[0.92rem] outline-none transition placeholder:text-dim hover:bg-white/[0.06] focus:border-violet/60 focus:bg-ink" + /> + / + + ) +} + +export function AppShell() { + const activeDownloads = useActiveDownloads() + const toast = useToast() + const [canStop, setCanStop] = useState(false) + const [confirmStop, setConfirmStop] = useState(false) + const [stopping, setStopping] = useState(false) + const [stopped, setStopped] = useState(false) + const location = useLocation() + + useEffect(() => { + api.session().then((s) => setCanStop(s.shutdown_enabled)).catch(() => {}) + }, []) + + useEffect(() => { + window.scrollTo({ top: 0 }) + }, [location.pathname]) + + async function stop() { + setStopping(true) + try { + await api.shutdown() + setStopped(true) + } catch (err) { + toast.show(errorMessage(err, 'Не удалось остановить AnimeBox'), 'error') + } finally { + setStopping(false) + setConfirmStop(false) + } + } + + if (stopped) { + return ( +
+
+ +

AnimeBox остановлен

+

Вкладку можно закрыть. Незавершённые загрузки снова встанут в очередь при следующем запуске.

+
+
+ ) + } + + return ( +
+ + Перейти к содержимому + + + + +
+
+ + + + + {canStop && ( + + )} +
+
+ +
+ +
+ + + + void stop()} + onCancel={() => setConfirmStop(false)} + > + Локальный сервер завершит работу и освободит порт. {activeDownloads > 0 && `Активные загрузки (${activeDownloads}) снова встанут в очередь при следующем запуске; HLS-серии начнутся заново.`} + +
+ ) +} diff --git a/frontend/src/components/ContinueCard.tsx b/frontend/src/components/ContinueCard.tsx new file mode 100644 index 0000000..a527f0e --- /dev/null +++ b/frontend/src/components/ContinueCard.tsx @@ -0,0 +1,45 @@ +import { Link } from 'react-router-dom' +import type { WatchHistory } from '../api/types' +import { formatTime, progressRatio } from '../lib/format' +import { continueTarget } from '../lib/routes' +import { Icon } from './icons' + +/** Landscape card with the poster as a blurred backdrop — used in "Продолжить просмотр". */ +export function ContinueCard({ item }: { item: WatchHistory }) { + const ratio = progressRatio(item.progress_seconds, item.duration_seconds) + return ( + + {item.poster && ( + + )} +
+ {item.poster && } +
+
+
+

{item.anime_title}

+

+ Серия {item.episode} + {item.voiceover ? ` · ${item.voiceover}` : ''} + {item.local_episode_id ? ' · офлайн' : ''} +

+
+
+
+ {formatTime(item.progress_seconds)} + {item.duration_seconds ? formatTime(item.duration_seconds) : ''} +
+
+
+
+
+
+ + + + + ) +} diff --git a/frontend/src/components/EpisodeGrid.tsx b/frontend/src/components/EpisodeGrid.tsx new file mode 100644 index 0000000..b662b41 --- /dev/null +++ b/frontend/src/components/EpisodeGrid.tsx @@ -0,0 +1,104 @@ +import { memo } from 'react' +import type { EpisodeInfo } from '../api/types' +import { cx } from '../lib/cx' + +export type EpisodeState = { + progress: number // 0..1 + completed: boolean + downloaded: boolean + missing: boolean + queued: boolean +} + +const EMPTY: EpisodeState = { progress: 0, completed: false, downloaded: false, missing: false, queued: false } + +function describe(ep: EpisodeInfo, state: EpisodeState): string { + const parts = [`Серия ${ep.ordinal}`] + if (ep.title && ep.title !== `Episode ${ep.ordinal}`) parts.push(ep.title) + if (state.completed) parts.push('просмотрена') + else if (state.progress > 0) parts.push(`просмотрено ${Math.round(state.progress * 100)}%`) + if (state.downloaded) parts.push('доступна офлайн') + else if (state.queued) parts.push('в очереди загрузки') + return parts.join(', ') +} + +const EpisodeTile = memo(function EpisodeTile({ ep, state, selected, onSelect }: { + ep: EpisodeInfo + state: EpisodeState + selected: boolean + onSelect: (ordinal: number) => void +}) { + return ( + + ) +}) + +export function EpisodeGrid({ episodes, states, selected, onSelect }: { + episodes: EpisodeInfo[] + states: Map + selected: number | null + onSelect: (ordinal: number) => void +}) { + return ( +
+ {episodes.map((ep) => ( + + ))} +
+ ) +} + +export function EpisodeLegend() { + return ( +
+ + офлайн + + + прогресс + + + в очереди + +
+ ) +} diff --git a/frontend/src/components/PosterCard.tsx b/frontend/src/components/PosterCard.tsx new file mode 100644 index 0000000..64ada01 --- /dev/null +++ b/frontend/src/components/PosterCard.tsx @@ -0,0 +1,86 @@ +import { useState, type ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { usePrefs } from '../lib/prefs' +import { Icon } from './icons' +import { Skeleton } from './ui' +import { cx } from '../lib/cx' + +function PosterImage({ src, title }: { src: string | null | undefined; title: string }) { + const [failed, setFailed] = useState(false) + if (!src || failed) { + return ( +
+ {title} +
+ ) + } + return ( + setFailed(true)} + className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]" + /> + ) +} + +export function PosterCard({ to, title, poster, meta, badge, progress }: { + to: string + title: string + poster?: string | null + meta?: ReactNode + badge?: ReactNode + progress?: number +}) { + return ( + +
+ + {/* Light leak from the top edge on hover: the "projector" moment of the card. */} +
+ {badge &&
{badge}
} + + + + {progress !== undefined && progress > 0 && ( +
+
+
+ )} +
+
+

{title}

+ {meta &&
{meta}
} +
+ + ) +} + +export function PosterGrid({ children, className }: { children: ReactNode; className?: string }) { + const { compactGrid } = usePrefs() + return ( +
+ {children} +
+ ) +} + +export function PosterGridSkeleton({ count = 12 }: { count?: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( +
+ + + +
+ ))} +
+ ) +} diff --git a/frontend/src/components/components.test.tsx b/frontend/src/components/components.test.tsx new file mode 100644 index 0000000..6e20393 --- /dev/null +++ b/frontend/src/components/components.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, ApiError } from '../api/client' +import { EpisodeGrid, type EpisodeState } from './EpisodeGrid' +import { EmptyState, ProgressBar } from './ui' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('EpisodeGrid', () => { + const episodes = [1, 2, 3].map((n) => ({ ordinal: n, season: 1, title: `Episode ${n}` })) + const states = new Map([ + [1, { progress: 1, completed: true, downloaded: true, missing: false, queued: false }], + [2, { progress: 0.4, completed: false, downloaded: false, missing: false, queued: true }], + ]) + + it('describes each episode state for assistive technology and selects on click', () => { + const onSelect = vi.fn() + render() + expect(screen.getByRole('button', { name: /Серия 1, просмотрена, доступна офлайн/ })).toBeTruthy() + const second = screen.getByRole('button', { name: /Серия 2, просмотрено 40%, в очереди загрузки/ }) + expect(second.getAttribute('aria-pressed')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: 'Серия 3' })) + expect(onSelect).toHaveBeenCalledWith(3) + }) +}) + +describe('ui primitives', () => { + it('clamps progress values', () => { + render() + expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('100') + }) + + it('renders empty states with an action', () => { + render(Найти}>Подсказка) + expect(screen.getByRole('heading', { name: 'Библиотека пуста' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Найти' })).toBeTruthy() + }) +}) + +describe('api client', () => { + it('surfaces structured backend errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ detail: { code: 'provider_timeout', message: 'details timed out', stage: 'details', source: 'animego' } }), { + status: 504, + headers: { 'Content-Type': 'application/json' }, + }), + ) + const error = await api.anime('animego:%2Fanime%2Fx').catch((e) => e) + expect(error).toBeInstanceOf(ApiError) + expect([error.status, error.code, error.stage, error.source, error.message]).toEqual([504, 'provider_timeout', 'details', 'animego', 'details timed out']) + }) + + it('reports an unreachable backend in plain language', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')) + const error = await api.health().catch((e) => e) + expect(error.code).toBe('offline') + expect(error.message).toMatch(/AnimeBox не отвечает/) + }) + + it('sends anime ids as query parameters', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('[]', { status: 200 })) + await api.libraryEpisodes('animego:%2Fanime%2Fx') + expect(spy.mock.calls[0][0]).toBe('/api/library/episodes?id=animego%3A%252Fanime%252Fx') + }) +}) diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..2a1bd3d --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,176 @@ +import type { SVGProps } from 'react' + +const paths = { + home: , + search: ( + <> + + + + ), + library: ( + <> + + + + + ), + download: ( + <> + + + + ), + settings: ( + <> + + + + ), + play: , + pause: ( + <> + + + + ), + next: ( + <> + + + + ), + prev: ( + <> + + + + ), + back10: ( + <> + + + + ), + fwd10: ( + <> + + + + ), + volume: ( + <> + + + + ), + mute: ( + <> + + + + ), + fullscreen: , + exitFullscreen: , + list: , + mic: ( + <> + + + + ), + cc: ( + <> + + + + ), + gauge: ( + <> + + + + ), + layers: ( + <> + + + + ), + arrowLeft: , + chevronRight: , + chevronDown: , + close: , + check: , + retry: ( + <> + + + + ), + trash: , + offline: ( + <> + + + + ), + alert: ( + <> + + + + ), + info: ( + <> + + + + ), + power: ( + <> + + + + ), + folder: , + sort: , + plus: , + up: , + down: , + copy: ( + <> + + + + ), + pulse: , + film: ( + <> + + + + ), +} + +export type IconName = keyof typeof paths + +export function Icon({ name, size = 20, ...props }: { name: IconName; size?: number } & SVGProps) { + return ( + + ) +} diff --git a/frontend/src/components/toast.tsx b/frontend/src/components/toast.tsx new file mode 100644 index 0000000..cadbe97 --- /dev/null +++ b/frontend/src/components/toast.tsx @@ -0,0 +1,63 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react' +import { Icon } from './icons' +import { cx } from '../lib/cx' + +type ToastTone = 'success' | 'error' | 'info' +type Toast = { id: number; tone: ToastTone; text: string; action?: { label: string; onClick: () => void } } +type ToastApi = { show: (text: string, tone?: ToastTone, action?: Toast['action']) => void } + +const ToastContext = createContext({ show: () => {} }) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const counter = useRef(0) + + const dismiss = useCallback((id: number) => setToasts((list) => list.filter((t) => t.id !== id)), []) + const show = useCallback( + (text: string, tone: ToastTone = 'success', action?: Toast['action']) => { + const id = ++counter.current + setToasts((list) => [...list.slice(-3), { id, tone, text, action }]) + window.setTimeout(() => dismiss(id), tone === 'error' ? 7000 : 4000) + }, + [dismiss], + ) + const value = useMemo(() => ({ show }), [show]) + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ + {toast.text} + {toast.action && ( + + )} +
+ ))} +
+
+ ) +} + +// eslint-disable-next-line react-refresh/only-export-components -- hook belongs with its provider +export function useToast(): ToastApi { + return useContext(ToastContext) +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..df68c2f --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,312 @@ +import { useEffect, useId, useRef, type ButtonHTMLAttributes, type ReactNode } from 'react' +import { Link } from 'react-router-dom' +import type { ProviderStatus } from '../api/types' +import { cx } from '../lib/cx' +import { Icon, type IconName } from './icons' + + + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' +type Size = 'sm' | 'md' | 'lg' + +const variantClass: Record = { + primary: + 'bg-violet text-white shadow-[0_8px_26px_-10px_rgba(139,108,255,0.9)] hover:bg-violet-hi active:bg-violet-lo disabled:bg-violet/45 disabled:shadow-none', + secondary: 'border border-line bg-white/[0.04] text-paper hover:border-white/15 hover:bg-white/[0.08] disabled:opacity-50', + ghost: 'text-fog hover:bg-white/[0.06] hover:text-paper disabled:opacity-40', + danger: 'border border-danger/25 bg-danger/10 text-danger hover:bg-danger/18 disabled:opacity-50', +} + +const sizeClass: Record = { + sm: 'h-8 gap-1.5 rounded-lg px-3 text-[0.82rem]', + md: 'h-10 gap-2 rounded-xl px-4 text-[0.9rem]', + lg: 'h-12 gap-2.5 rounded-xl px-5 text-[0.98rem]', +} + +type ButtonProps = ButtonHTMLAttributes & { + variant?: Variant + size?: Size + icon?: IconName + loading?: boolean +} + +export function Button({ variant = 'secondary', size = 'md', icon, loading, className, children, disabled, ...props }: ButtonProps) { + return ( + + ) +} + +export function ButtonLink({ to, variant = 'secondary', size = 'md', icon, className, children }: { + to: string + variant?: Variant + size?: Size + icon?: IconName + className?: string + children: ReactNode +}) { + return ( + + {icon && } + {children} + + ) +} + +export function IconButton({ icon, label, size = 40, className, active, ...props }: ButtonHTMLAttributes & { + icon: IconName + label: string + size?: number + active?: boolean +}) { + return ( + + ) +} + +export function Spinner({ size = 18, className }: { size?: number; className?: string }) { + return ( + + ) +} + +type Tone = 'neutral' | 'violet' | 'offline' | 'danger' | 'warn' +const toneClass: Record = { + neutral: 'bg-white/[0.06] text-fog', + violet: 'bg-violet/16 text-violet-text', + offline: 'bg-offline/12 text-offline', + danger: 'bg-danger/12 text-danger', + warn: 'bg-warn/12 text-warn', +} + +export function Badge({ tone = 'neutral', children, className }: { tone?: Tone; children: ReactNode; className?: string }) { + return ( + + {children} + + ) +} + +export function ProgressBar({ value, className, tone = 'violet', label }: { value: number; className?: string; tone?: 'violet' | 'offline' | 'danger'; label?: string }) { + const pct = Math.max(0, Math.min(100, value)) + const fill = tone === 'offline' ? 'bg-offline' : tone === 'danger' ? 'bg-danger' : 'bg-violet' + return ( +
+
+
+ ) +} + +export function Skeleton({ className }: { className?: string }) { + return