diff --git a/.env.example b/.env.example index b8dca27..9f72cfd 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,46 @@ # AnimeBox runtime configuration # Copy to .env. Never commit .env. +# Most of these can also be changed in the UI (Settings); UI values take precedence. -# Local web server. Keep loopback unless you intentionally expose AnimeBox to LAN. +# Local web server. Keep loopback unless you intentionally expose AnimeBox to your LAN. HOST=127.0.0.1 PORT=8787 +# Extra Host header values to accept besides 127.0.0.1/localhost (JSON list), e.g. a LAN hostname. +# Requests with any other Host are rejected to block DNS-rebinding attacks. +# ALLOWED_HOSTS=["animebox.lan"] -# Relative paths resolve from project root. Absolute paths also work. +# Relative paths resolve from the AnimeBox folder. Absolute paths also work. LIBRARY_PATH=./library +DATA_PATH=./data DATABASE_URL=sqlite+aiosqlite:///./data/animebox.db # DEBUG, INFO, WARNING, ERROR LOG_LEVEL=INFO -# Optional proxy for geo-restricted providers. -# Accepted examples: http://127.0.0.1:7890 or socks5://user:password@host:1080 +# Optional proxy for search, playback and downloads (they always share one network path). +# Examples: http://127.0.0.1:7890 or socks5://user:password@host:1080 # HTTP_PROXY= -# Maximum time for each search provider. Slow or blocked providers are skipped. +# Per-stage time limits for each provider, in seconds. Slow providers are reported, not waited for. PROVIDER_SEARCH_TIMEOUT=10 +PROVIDER_DETAIL_TIMEOUT=25 +PROVIDER_STREAM_TIMEOUT=25 -# Voiceover names, comma-separated in priority order. -PREFERRED_VOICEOVERS=AniLibria,AnimeVost,Aniliberty,Animevost +# Voiceover names, comma-separated, in priority order (case-insensitive substring match). +PREFERRED_VOICEOVERS=AniLibria,AnimeVost,Aniliberty -# Closest available stream quality is selected. +# Closest available quality not above this value is selected (360, 480, 720, 1080). DEFAULT_QUALITY=1080 -# Steam Deck optimization defaults used when enabled in UI. +# Parallel downloads (1-6). +MAX_CONCURRENT_DOWNLOADS=2 + +# Steam Deck optimization defaults used when enabled in Settings. STEAM_DECK_CRF=23 STEAM_DECK_HEIGHT=720 -# CORS origins use pydantic JSON list syntax when overridden. +# Idle lifetime of a playback session (seconds). Expired sessions are re-resolved automatically. +MEDIA_SESSION_TTL=14400 + +# CORS origins for the Vite dev server (JSON list). # CORS_ORIGINS=["http://127.0.0.1:5173","http://localhost:5173"] 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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ba9426 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_call: + outputs: + version: + description: AnimeBox version from backend/__init__.py + value: ${{ jobs.checks.outputs.version }} + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - uses: actions/setup-node@v5 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Read version + id: version + run: | + version=$(python -c "import re,pathlib; print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('backend/__init__.py').read_text()).group(1))") + frontend=$(node -p "require('./frontend/package.json').version") + if [ "$version" != "$frontend" ]; then + echo "backend version $version != frontend/package.json $frontend" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Fetch pinned anicli-api and apply compatibility patch + run: python scripts/patch_anicli.py --checkout + + - name: Install backend dependencies + run: python -m pip install -r backend/requirements.txt + + - name: Backend tests + run: python -m pytest -q + + - name: Frontend install + working-directory: frontend + run: npm ci + + - name: Frontend type-check, lint and tests + working-directory: frontend + run: npm run check + + - name: Frontend production build + working-directory: frontend + run: npm run build + + - name: No agent or temporary files are tracked + run: | + if git ls-files | grep -E '^(\.tmp|\.claude|\.codex|\.cursor)/'; then + echo "Local agent/workspace files must not be committed" >&2 + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 065a52d..c487df2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,36 +1,73 @@ name: Portable release +# Tags vX.Y.Z publish a release; vX.Y.Z-suffix (e.g. v2.0.0-beta.1) publish a prerelease. +# Manual runs (workflow_dispatch) build and test every platform and upload workflow artifacts only. on: workflow_dispatch: push: tags: ['v*'] permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: + checks: + uses: ./.github/workflows/ci.yml + + meta: + needs: checks + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + steps: + - id: meta + env: + CODE_VERSION: ${{ needs.checks.outputs.version }} + run: | + version="$CODE_VERSION" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + tag="${GITHUB_REF#refs/tags/v}" + if [[ "$tag" != "$version" ]]; then + echo "Tag v$tag does not match backend.__version__ $version" >&2 + exit 1 + fi + fi + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "Version $version is not semver" >&2 + exit 1 + fi + prerelease=false + [[ "$version" == *-* ]] && prerelease=true + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + build: + needs: meta strategy: fail-fast: false matrix: include: - os: windows-latest - name: Windows-x64 + platform: Windows-x64 ffmpeg_url: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip - target_name: AnimeBox-Windows-x64 - - os: ubuntu-latest - name: Linux-x64 + - os: ubuntu-22.04 # older glibc keeps the Linux build compatible with SteamOS and most distributions + platform: Linux-x64 ffmpeg_url: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz - target_name: AnimeBox-Linux-x64 - os: macos-15-intel - name: macOS-x64 + platform: macOS-x64 ffmpeg_url: system - target_name: AnimeBox-macOS-x64 - os: macos-14 - name: macOS-arm64 + platform: macOS-arm64 ffmpeg_url: system - target_name: AnimeBox-macOS-arm64 runs-on: ${{ matrix.os }} + env: + TARGET_NAME: AnimeBox-${{ needs.meta.outputs.version }}-${{ matrix.platform }} + VERSION: ${{ needs.meta.outputs.version }} steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 @@ -39,15 +76,12 @@ jobs: cache: pip - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '24' cache: npm cache-dependency-path: frontend/package-lock.json - - name: Checkout anicli-api - shell: bash - run: | - git clone --depth 1 https://github.com/vypivshiy/anicli-api .references/anicli-api - python scripts/patch_anicli.py + - name: Fetch pinned anicli-api and apply compatibility patch + run: python scripts/patch_anicli.py --checkout - name: Install Python dependencies run: python -m pip install -r backend/requirements.txt -r release/requirements-build.txt @@ -77,24 +111,12 @@ jobs: '--retry', '5', '--retry-delay', '5', '--retry-all-errors', '--connect-timeout', '30', '--max-time', '600' ) - & curl.exe @curlArgs --output ffmpeg.zip $url if ($LASTEXITCODE -ne 0) { throw "ffmpeg download failed with curl exit code ${LASTEXITCODE}: $url" } - $archive = Get-Item ffmpeg.zip -ErrorAction Stop if ($archive.Length -le 0) { throw 'downloaded ffmpeg archive is empty' } $actual = (Get-FileHash ffmpeg.zip -Algorithm SHA256).Hash.ToLowerInvariant() Write-Host "ffmpeg archive: $($archive.Length) bytes, SHA-256 $actual" - - Add-Type -AssemblyName System.IO.Compression.FileSystem - $zip = [System.IO.Compression.ZipFile]::OpenRead($archive.FullName) - try { - $ffmpegEntry = $zip.Entries | Where-Object { $_.FullName -match '(^|/)bin/ffmpeg\.exe$' } | Select-Object -First 1 - if (-not $ffmpegEntry) { throw 'ffmpeg archive does not contain bin/ffmpeg.exe' } - } finally { - $zip.Dispose() - } - Expand-Archive ffmpeg.zip -DestinationPath ffmpeg-package $ffmpeg = Get-ChildItem ffmpeg-package -Recurse -File -Filter ffmpeg.exe | Where-Object { $_.FullName -match '[\\/]bin[\\/]ffmpeg\.exe$' } | @@ -110,12 +132,18 @@ jobs: run: echo "FFMPEG_PATH=$(command -v ffmpeg)" >> "$GITHUB_ENV" - name: Build portable archive - run: python release/build_portable.py --ffmpeg "${{ env.FFMPEG_PATH }}" --target-name "${{ matrix.target_name }}" + run: python release/build_portable.py --ffmpeg "${{ env.FFMPEG_PATH }}" --version "${{ env.VERSION }}" --target-name "${{ env.TARGET_NAME }}" + + - name: Verify archive contents and checksum + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then ext=tar.gz; else ext=zip; fi + python release/verify_archive.py "release/artifacts/$TARGET_NAME.$ext" --version "$VERSION" - name: Verify packaged ffmpeg shell: bash run: | - ffmpeg="release/artifacts/${{ matrix.target_name }}/tools/ffmpeg" + ffmpeg="release/artifacts/$TARGET_NAME/tools/ffmpeg" if [[ "${{ runner.os }}" == "Windows" ]]; then ffmpeg="${ffmpeg}.exe" fi @@ -137,12 +165,12 @@ jobs: if: runner.os == 'macOS' shell: bash run: | - archive="release/artifacts/${{ matrix.target_name }}.zip" + archive="release/artifacts/$TARGET_NAME.zip" extract_dir="$RUNNER_TEMP/animebox-permission-test" rm -rf "$extract_dir" mkdir -p "$extract_dir" ditto -x -k "$archive" "$extract_dir" - portable="$extract_dir/${{ matrix.target_name }}" + portable="$extract_dir/$TARGET_NAME" test -f "$portable/scripts/grant-macos-permissions.sh" chmod a-x "$portable/AnimeBox" "$portable/tools/ffmpeg" bash "$portable/scripts/grant-macos-permissions.sh" "$portable" @@ -150,78 +178,101 @@ jobs: test -x "$portable/tools/ffmpeg" "$portable/tools/ffmpeg" -version - - name: Smoke test packaged server + - name: Smoke test packaged app shell: bash + env: + ANIMEBOX_NO_BROWSER: '1' run: | python - <<'PY' - import os, pathlib, platform, subprocess, time, urllib.request - artifacts = pathlib.Path('release/artifacts') - folder = next(p for p in artifacts.iterdir() if p.is_dir() and p.name.startswith('AnimeBox-')) + import json, os, pathlib, platform, socket, subprocess, time, urllib.error, urllib.request + name = os.environ['TARGET_NAME'] + version = os.environ['VERSION'] + folder = pathlib.Path('release/artifacts') / name exe = (folder / ('AnimeBox.exe' if platform.system() == 'Windows' else 'AnimeBox')).resolve() - env = dict(os.environ) - env['PATH'] = str((folder / 'tools').resolve()) + os.pathsep + env.get('PATH', '') launch_cwd = pathlib.Path('release/smoke-cwd').resolve() launch_cwd.mkdir(parents=True, exist_ok=True) - proc = subprocess.Popen( - [str(exe)], - cwd=str(launch_cwd), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) + base = 'http://127.0.0.1:8787' + + def get(path, timeout=10, headers=None): + request = urllib.request.Request(base + path, headers=headers or {}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read() + + proc = subprocess.Popen([str(exe)], cwd=str(launch_cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) try: - for _ in range(120): + for _ in range(160): if proc.poll() is not None: - output = proc.stdout.read() if proc.stdout else '' - raise SystemExit(f'portable app exited with {proc.returncode}:\n{output}') + raise SystemExit(f'portable app exited with {proc.returncode}:\n{proc.stdout.read()}') try: - with urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=1) as response: - if response.status == 200: - print('portable health: OK') - break + status, body = get('/api/health', timeout=1) + if status == 200: + break except OSError: - time.sleep(.25) + time.sleep(0.25) else: - output = proc.stdout.read() if proc.poll() is not None and proc.stdout else '' - raise SystemExit(f'portable app failed health check:\n{output}') + raise SystemExit('portable app failed health check') + health = json.loads(body) + assert health['version'] == version, health + status, html = get('/') + assert status == 200 and b'
' in html, 'bundled UI missing' + status, body = get('/api/providers') + assert status == 200 and json.loads(body)[0]['id'] == 'animego' + status, body = get('/api/library') + assert status == 200 and json.loads(body) == [], 'fresh install must have an empty library' + status, body = get('/api/home') + assert status == 200 + diagnostics = json.loads(get('/api/diagnostics')[1]) + assert diagnostics['ffmpeg']['path'], 'bundled ffmpeg not detected' + try: + get('/api/health', headers={'Host': 'attacker.example'}) + raise SystemExit('foreign Host header was accepted') + except urllib.error.HTTPError as exc: + assert exc.code == 400 if not (folder / 'data' / 'animebox.db').is_file(): - raise SystemExit('portable database was not created inside package data directory') - with urllib.request.urlopen('http://127.0.0.1:8787/api/sources', timeout=10) as response: - if response.status != 200: - raise SystemExit(f'portable sources check returned HTTP {response.status}') - with urllib.request.urlopen('http://127.0.0.1:8787/api/search?q=k-on&source=animego', timeout=30) as response: - if response.status != 200: - raise SystemExit(f'portable search check returned HTTP {response.status}') + raise SystemExit('database was not created inside the portable data directory') if (launch_cwd / 'data').exists() or (launch_cwd / 'library').exists(): - raise SystemExit('portable runtime files leaked into launch working directory') - with urllib.request.urlopen('http://127.0.0.1:8787/api/session', timeout=5) as response: - token = __import__('json').load(response)['shutdown_token'] - request = urllib.request.Request( - 'http://127.0.0.1:8787/api/shutdown', - method='POST', - headers={'X-AnimeBox-Token': token}, - ) + raise SystemExit('runtime files leaked into the launch working directory') + token = json.loads(get('/api/session')[1])['shutdown_token'] + request = urllib.request.Request(base + '/api/shutdown', method='POST', headers={'X-AnimeBox-Token': token}) with urllib.request.urlopen(request, timeout=5) as response: - if response.status != 200: - raise SystemExit(f'portable shutdown returned HTTP {response.status}') - proc.wait(timeout=15) + assert response.status == 200 + proc.wait(timeout=20) if proc.returncode != 0: - output = proc.stdout.read() if proc.stdout else '' - raise SystemExit(f'portable shutdown exited with {proc.returncode}:\n{output}') - with __import__('socket').socket() as sock: + raise SystemExit(f'portable shutdown exited with {proc.returncode}:\n{proc.stdout.read()}') + with socket.socket() as sock: if sock.connect_ex(('127.0.0.1', 8787)) == 0: raise SystemExit('portable shutdown did not release port 8787') + if (folder / 'data' / 'animebox.db-wal').exists(): + raise SystemExit('database WAL was not checkpointed on shutdown') + print('portable smoke test: OK') finally: if proc.poll() is None: proc.terminate() - try: proc.wait(timeout=10) - except subprocess.TimeoutExpired: proc.kill() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + PY + + - name: Live provider check (informational) + continue-on-error: true + shell: bash + run: | + python - <<'PY' + import asyncio + from backend.providers.registry import ProviderRegistry + async def main(): + registry = ProviderRegistry() + await registry.check('animego') + for stage, result in registry.health.get('animego').stages.items(): + print(f"{stage:10} {'ok' if result.ok else 'FAIL'} {result.latency_ms} ms {result.error or ''}") + await registry.aclose() + asyncio.run(main()) PY - uses: actions/upload-artifact@v4 with: - name: AnimeBox-${{ matrix.name }} + name: ${{ env.TARGET_NAME }} path: | release/artifacts/*.zip release/artifacts/*.tar.gz @@ -230,23 +281,44 @@ jobs: publish: if: startsWith(github.ref, 'refs/tags/v') - needs: build + needs: [meta, build] runs-on: ubuntu-latest + permissions: + contents: write steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true - - name: Build checksum manifest - shell: bash + + - name: Verify all platform archives and build checksum manifest + env: + VERSION: ${{ needs.meta.outputs.version }} run: | cd artifacts - sha256sum *.zip *.tar.gz 2>/dev/null > SHA256SUMS.txt + expected="AnimeBox-$VERSION-Windows-x64.zip AnimeBox-$VERSION-Linux-x64.tar.gz AnimeBox-$VERSION-macOS-x64.zip AnimeBox-$VERSION-macOS-arm64.zip" + for file in $expected; do + test -s "$file" || { echo "missing $file — refusing to publish an incomplete release" >&2; exit 1; } + done + for file in *.sha256; do sha256sum -c "$file"; done + sha256sum $expected > SHA256SUMS.txt + cat SHA256SUMS.txt + python ../release/verify_archive.py $expected --version "$VERSION" + + - name: Release notes + run: python release/release_notes.py "${{ needs.meta.outputs.version }}" > release-notes.md + - uses: softprops/action-gh-release@v2 with: + name: AnimeBox ${{ needs.meta.outputs.version }} + body_path: release-notes.md + generate_release_notes: true + prerelease: ${{ needs.meta.outputs.prerelease == 'true' }} + fail_on_unmatched_files: true files: | artifacts/*.zip artifacts/*.tar.gz artifacts/*.sha256 artifacts/SHA256SUMS.txt - generate_release_notes: true 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f0d669 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +All notable changes to AnimeBox are documented here. Versions follow [Semantic Versioning](https://semver.org/). + +## [2.0.0] — 2026-10-11 + +AnimeBox 2.0 is a reliability release with a completely redesigned interface. Existing libraries, history, +progress and settings are migrated automatically (a backup of the database is made first). + +### Fixed + +- **Browsing no longer adds anime to the offline library.** Opening a title page created its folder and the + library listed every browsed title, even with zero downloaded episodes. The library is now derived only from + downloaded files that exist and pass validation; missing, deleted and partial files are excluded. +- The same anime could be stored under two ids (`animego:%2Fanime%2F…` and `animego:/anime/…`) because route + parameters were decoded twice. Ids are canonicalised everywhere and existing duplicates are merged. +- AnimeGO playback failed for many voiceovers: AnimeBox always picked the first player for a voiceover, and AnimeGO's + CVH player URLs changed so the upstream extractor returned nothing. AnimeBox now falls back across all players of a + voiceover (Kodik, Aniboom, CVH, …) and includes an adapter for the current CVH URL format. +- Recovering expired search data could silently open a *different* title with a similar name. Recovery now accepts + only an exact provider match. +- With `HTTP_PROXY` set, OKCDN/CVH streams resolved through the proxy but were fetched directly, so the IP-bound links + returned 403. Resolution, playback and downloads now share one network path. +- Online playback progress was saved but never restored. +- "Next episode" no longer navigates to episodes that do not exist. +- A valid download was marked failed when the poster or metadata could not be saved. +- Download progress was largely fake (fixed 1/5/10/85/90 % steps); cancelling did not stop a running ffmpeg process. +- Database file paths were stored relative to the process working directory; they are now relative to the library. +- The portable launcher never wrote `data/animebox.log`. +- Shutting down left the SQLite WAL un-checkpointed, so copying only `animebox.db` could lose recent changes. +- The anicli-api compatibility patch failed to apply on fresh Windows installs because of CRLF line endings. + +### Security + +- The media proxy no longer fetches arbitrary URLs. Playback uses short-lived media sessions; clients can request only + URLs the server itself issued, every hop (including redirects) is checked against loopback/private/link-local + networks, and provider headers stay scoped to their session. +- Requests whose `Host` header is not this machine are rejected (protection against DNS rebinding). +- Proxy credentials and signed URL parameters are redacted from logs. +- Settings and API input are validated. + +### Providers + +- New provider engine with per-stage timeouts (search, details, episodes, voiceovers, stream) and typed errors. +- Provider health is based on real operations and on an explicit end-to-end check; a provider is shown as working + only when every stage most recently succeeded. +- One isolated HTTP session per source; connection errors are retried once, HTTP 403/429 and anti-bot pages are not. +- AnimeGO is the default and the only provider marked stable; AniLiberty, AnimeVost and Yummy Anime are experimental; + AniLib, DreamCast and SameBand are opt-in. HDRezka is no longer offered because its extractor depends on an + anti-bot workaround. +- anicli-api is pinned to a tested commit (`patches/anicli-api.ref`). + +### Player + +- Custom controls: keyboard shortcuts, volume, speed, quality (HLS levels or per-quality streams), voiceover switching + without losing the position, audio tracks and subtitles when the stream provides them, fullscreen, episode list. +- Progress is saved periodically, on pause/seek, when the tab is hidden and when leaving the page, and restored on open. +- Network errors are retried; expired links are re-resolved automatically and playback resumes at the same position. +- Clear error messages with actions (retry, other voiceover, play the downloaded copy). +- Next-episode countdown only when the next episode exists (can be disabled in Settings). + +### Downloads + +- Configurable parallel downloads (1–6), real progress from ffmpeg's position/duration, transfer speed and size. +- Pause/resume for direct MP4/WebM downloads (HTTP Range); HLS downloads restart and say so. +- Automatic retries with a fresh stream resolution; cancellation stops ffmpeg; duplicate requests are skipped. +- Files are written to `*.part.*`, validated (size, readable container, duration ≥ 95 % of the source), then moved into + place atomically. Interrupted jobs return to the queue after a restart; orphaned partial files are cleaned up. +- Steam Deck transcoding keeps the original file if transcoding fails. + +### Interface + +- New "Neon Cinema" design: bundled Unbounded and Manrope fonts (Latin + Cyrillic, works offline), redesigned Home, + Search, Title, Player, Library, Downloads and Settings pages, responsive layouts for desktop, Steam Deck and phones. +- Home: continue watching, recently watched, recently downloaded, quick search. +- Title page: episode grid showing watch progress, offline availability and queued downloads; works from the local + library when the source is offline. +- Library: storage usage, sorting, filtering, rescanning, deletion with confirmation. +- Settings: playback, downloads, providers (status per stage, manual check), network, storage, interface, diagnostics. + +### Under the hood + +- Versioned SQLite migrations with automatic backups in `data/backups/`, unique constraints and indexes. +- Backend split into focused API modules; backend (pytest) and frontend (Vitest) test suites run in CI on every push. +- Release workflow builds Windows x64, Linux x64 (Steam Deck), macOS x64 and macOS arm64 archives named + `AnimeBox--`, verifies their contents, smoke-tests the packaged app, and publishes checksums. + +## [1.1.3] and earlier + +Early MVP releases: multi-source search via anicli-api, online HLS playback through a local proxy, downloads, +local library, Steam Deck optimisation and portable builds. See the Git history for details. diff --git a/README.md b/README.md index 8ea6088..f17a961 100644 --- a/README.md +++ b/README.md @@ -2,231 +2,217 @@ **English** | [Русский](README.ru.md) -A local anime library for searching, watching, and traveling without internet access. AnimeBox runs on your computer, stores episodes as regular files, and requires no account or cloud service. +A local anime library for Russian-language anime sites. Search, pick a voiceover, watch in the browser, or download +episodes and watch them later without internet. AnimeBox runs on your computer, stores episodes as regular files, and +needs no account or cloud service. -> **Status:** early MVP. Many bugs remain, and active development is ongoing. Source websites can change without notice, so individual providers may temporarily stop working. +> Source websites change without notice. AnimeGO is the recommended and best-tested source; others are marked +> experimental. AnimeBox shows each source's real status in **Settings → Sources**. ## Features -| Feature | Status | +| | | |---|---| -| Search across multiple sources | ✅ | -| Title details, episodes, and voiceovers | ✅ | -| Online HLS playback through a local proxy | ✅ | -| Episode or season downloads | 🟨 | -| Download queue, progress, cancellation, and retry | ✅ | -| Local library and offline player | ✅ | -| Saved watch progress | ✅ | -| Steam Deck optimization | ✅ | - -## Interface - -AnimeBox uses a dark cinematic interface with a violet accent. After launch, open `http://127.0.0.1:8787`. On a title page, choose a **Season voiceover**; it applies to playback and downloads for every episode. You can still open a specific episode's voiceover list and set an override for that episode. **Auto-select** follows the voiceover priority from Settings and uses the first available option when no preferred voiceover is present. - -> **Download recommendation:** the author recommends choosing **AnimeGO** whenever it is available because it currently works best and provides the most stable downloads. - -The **Shut down** button in the header stops the local server cleanly and releases port `8787`. Closing the browser tab alone does not stop the server, protecting active downloads from accidental interruption. - -## How the portable build works - -The archive does not contain two separate applications. `AnimeBox.exe`/`AnimeBox` is one Python launcher with a FastAPI backend and embedded static files from the prebuilt React/TypeScript frontend. Node.js is not included in the archive and does not run on the user's device; the browser receives regular HTML/CSS/JavaScript files from the local backend. - -The `.references/` directory contains local checkouts of upstream open-source projects and is not committed to Git. Installation scripts and CI clone `anicli-api`, then apply the tracked compatibility patch from `patches/`. Required compatibility changes therefore do not remain hidden inside a fully ignored checkout. - -## Portable releases +| Search | Several sources in parallel; a slow or broken source never blocks the others, and each source's result is shown | +| Title page | Description, episodes, voiceovers with their players, progress and offline status per episode | +| Watching | Built-in player with progress saving/restore, keyboard shortcuts, quality, speed, voiceover switching, audio tracks and subtitles when the stream provides them, automatic next episode | +| Reliability | If one player of a voiceover fails (e.g. CVH), the next one is tried (Kodik, Aniboom, …); expired streams are re-resolved without losing your position | +| Downloads | Episode or whole season, parallel queue, real progress and speed, retries, cancel; pause/resume for direct MP4 files | +| Library | Only episodes that really exist on disk; storage usage, sorting, filtering, deletion, rescan | +| Offline | Downloaded episodes, posters and progress work without internet, even when the source is down | +| Steam Deck | Linux build runs on SteamOS; optional H.265 transcoding to save space | + +### Sources + +| Source | Status | Notes | +|---|---|---| +| AnimeGO | **Stable**, default | Many voiceovers; several players per voiceover with automatic fallback | +| AniLiberty (AniLibria) | Experimental | Own voiceover only | +| AnimeVost | Experimental | Own voiceover only; direct MP4 (downloads can be paused) | +| Yummy Anime | Experimental | Some players are DASH-only: downloadable, not playable in the browser | +| AniLib, DreamCast, SameBand | Experimental, off by default | Small or region-dependent catalogs | + +HDRezka is not offered: its extractor relies on working around an anti-bot check. AnimeBox does not bypass CAPTCHAs +or other access restrictions. + +## Install (portable, recommended) + +Download the archive for your system from [GitHub Releases](https://github.com/endercodezz/AnimeBox/releases). +Python, Node.js and FFmpeg are bundled — nothing else to install. + +| Archive | Run | +|---|---| +| `AnimeBox--Windows-x64.zip` | Extract, open `AnimeBox.exe` | +| `AnimeBox--Linux-x64.tar.gz` | Linux x86_64 and Steam Deck: extract, then `./AnimeBox` | +| `AnimeBox--macOS-x64.zip` | Intel Mac: extract, open `AnimeBox` | +| `AnimeBox--macOS-arm64.zip` | Apple Silicon: extract, open `AnimeBox` | -For most users, the recommended option is to download the archive for their platform from GitHub Releases. Python, Node.js, Git, and a system FFmpeg installation are not required: the Python runtime, backend, compiled frontend, `anicli-api`, and FFmpeg are included. +AnimeBox starts a private server on `http://127.0.0.1:8787` and opens your browser. Closing the tab does not stop it — +use **Stop** in the sidebar so active downloads are not interrupted by accident. -AnimeBox remains a web application: the launcher starts a private server at `http://127.0.0.1:8787` and opens the system browser. No separate desktop interface is installed. +Verify a download with the published checksums: `sha256sum -c SHA256SUMS.txt --ignore-missing`. -| Archive | How to run | -|---|---| -| `AnimeBox-Windows-x64.zip` | extract the archive and open `AnimeBox.exe` | -| `AnimeBox-Linux-x64.tar.gz` | Linux x86_64, including Steam Deck/SteamOS; extract, then run `chmod +x AnimeBox && ./AnimeBox` | -| `AnimeBox-macOS-x64.zip` | Intel Mac; extract and open the launcher | -| `AnimeBox-macOS-arm64.zip` | Apple Silicon; extract and open the launcher | +**macOS:** unsigned builds may need **Control-click → Open** on first launch. If downloads report `ffmpeg failed`, run +`bash scripts/grant-macos-permissions.sh` in the AnimeBox folder (add `--clear-quarantine` only for archives from a +source you trust). -The portable folder already contains a base `.env`, `data/`, `library/`, bundled FFmpeg, and all runtime dependencies. Windows and Linux/Steam Deck archives use GPL FFmpeg builds from BtbN; macOS archives bundle FFmpeg with its relocated Homebrew libraries, so Homebrew is not required on the user's Mac. Internet access is required only for search, fetching new streams, and downloads. The downloaded library, local posters, MP4 files, and watch progress work offline; no external web fonts are used. +## Upgrading from 1.x -When updating, preserve and move these items into the new portable folder: +Your data lives in three places; nothing else needs to be kept: -- `.env` — settings; -- `data/` — database, history, and progress; +- `.env` — settings (1.x files work unchanged), +- `data/` — database (history, progress, download history, settings), - `library/` — downloaded episodes and posters. -An unsigned macOS build may require **Control-click → Open** on first launch. Fully extract the portable archive before running AnimeBox. If a download reports `ffmpeg failed`, open Terminal in the extracted AnimeBox folder and run: +**Portable:** extract the new version, then copy those three items from the old folder into the new one (replace the +empty ones). **From source:** `git pull`, then run the install script again. -```bash -bash scripts/grant-macos-permissions.sh -``` +On the first start AnimeBox 2.0: -If Gatekeeper still blocks FFmpeg and the archive came from a source you trust, explicitly clear quarantine from AnimeBox/FFmpeg files: +1. copies the database to `data/backups/animebox-v0-.db`; +2. merges duplicate entries created by the 1.x id bug, keeping the best progress; +3. converts stored paths to library-relative ones; +4. stops counting merely-browsed titles as library content (no files are deleted); +5. removes empty folders and abandoned `*.part.*` files left in `library/` by 1.x. -```bash -bash scripts/grant-macos-permissions.sh --clear-quarantine -``` - -This option does not disable Gatekeeper system-wide and does not change unrelated files. +If anything goes wrong, the migration is rolled back and the backup stays in `data/backups/`. Files referenced by the +database but missing on disk are hidden from the library; **Library → Rescan** removes those records and picks up +episodes that AnimeBox downloaded earlier but no longer tracks. -## Quick start from source +## Run from source -Running from source requires Python 3.12+, Node.js 20+ with npm, Git, and FFmpeg in `PATH`. Docker is not required. The installation script downloads the official `anicli-api` repository (MIT) into `.references/anicli-api`; the other reference projects are needed only for development. - -### Windows 11 +Requires Python 3.12+, Node.js 24+, Git, and FFmpeg in `PATH`. Docker is not used. The install script fetches a pinned, +tested commit of [anicli-api](https://github.com/vypivshiy/anicli-api) into `.references/anicli-api` and applies the +compatibility patch from `patches/`. ```powershell +# Windows git clone https://github.com/endercodezz/AnimeBox.git cd AnimeBox .\scripts\install.ps1 .\scripts\run.ps1 ``` -Double-click launch is also available: run `scripts\install.cmd`, then `scripts\run.cmd`. For diagnostics, use `scripts\check.cmd`. - -The first `install` or `run` creates a base `.env` from `.env.example`. An existing `.env` is never overwritten. - -### macOS / Linux / Steam Deck - ```bash +# macOS / Linux / Steam Deck git clone https://github.com/endercodezz/AnimeBox.git cd AnimeBox -chmod +x scripts/*.sh ./scripts/install.sh ./scripts/run.sh ``` -### Development mode - -```powershell -# Windows -.\scripts\run.ps1 -Dev -``` - -```bash -# macOS / Linux -./scripts/run.sh --dev -``` - -- Production UI + API: `http://127.0.0.1:8787` -- Vite UI in development mode: `http://127.0.0.1:5173` -- OpenAPI: `http://127.0.0.1:8787/docs` - -`run` builds the frontend, starts the API, waits for a successful health check, and only then opens the browser. Use `--skip-build` / `-SkipBuild` to keep the existing frontend build and `--no-browser` / `-NoBrowser` to avoid opening the browser. - -Check the installation without starting a download: - -```powershell -.\scripts\check.ps1 -``` - -```bash -./scripts/check.sh -``` +`scripts\install.cmd`, `scripts\run.cmd` and `scripts\check.cmd` allow double-click launch on Windows. +Development mode with hot reload: `run.ps1 -Dev` / `run.sh --dev` (UI on `http://127.0.0.1:5173`, API docs on +`http://127.0.0.1:8787/docs`). ## Configuration -On first launch, `.env.example` is copied to `.env`. Main options: +`.env` is created from `.env.example` on first run and never overwritten. Most options are also in **Settings** +(UI values take precedence). Notable options: -- `LIBRARY_PATH` — downloaded episode directory; -- `DATABASE_URL` — SQLite URL; -- `HTTP_PROXY` — optional HTTP/SOCKS proxy for restricted sources; -- `PROVIDER_SEARCH_TIMEOUT` — maximum wait for each search provider, `10` seconds by default; slow or blocked providers are skipped while results from available providers are returned; -- `PREFERRED_VOICEOVERS` — comma-separated voiceover priority for automatic selection; -- `DEFAULT_QUALITY` — preferred quality; -- `STEAM_DECK_CRF`, `STEAM_DECK_HEIGHT` — transcoding settings. +| Option | Meaning | +|---|---| +| `LIBRARY_PATH`, `DATA_PATH` | Where episodes and the database live (relative to the AnimeBox folder) | +| `HTTP_PROXY` | HTTP or SOCKS proxy used for search, playback **and** downloads | +| `PROVIDER_SEARCH_TIMEOUT`, `PROVIDER_DETAIL_TIMEOUT`, `PROVIDER_STREAM_TIMEOUT` | Per-stage time limits per source | +| `PREFERRED_VOICEOVERS` | Voiceover priority for automatic selection | +| `DEFAULT_QUALITY` | 360, 480, 720 or 1080 — closest available quality not above it | +| `MAX_CONCURRENT_DOWNLOADS` | Parallel downloads (1–6) | +| `ALLOWED_HOSTS` | Extra host names accepted besides `127.0.0.1`/`localhost` | -Most user-facing options are also available on the **Settings** page. +## Keyboard shortcuts (player) -## Data storage +| Key | Action | +|---|---| +| Space / K | Play / pause | +| ← / → | −5 s / +5 s | +| J / L | −10 s / +10 s | +| ↑ / ↓ | Volume | +| M | Mute | +| F | Fullscreen | +| Shift+N / Shift+P | Next / previous episode | +| < / > | Slower / faster | +| 0–9 | Jump to 0–90 % | +| / or Ctrl+K | Focus search (outside the player) | + +## Data layout ```text library/ └── Anime Name/ ├── poster.jpg - ├── metadata.json + ├── metadata.json # includes the AnimeBox id, used by Rescan └── Season 1/ ├── Episode 01.mp4 └── Episode 02.mp4 - data/ -├── animebox.db +├── animebox.db # SQLite (history, progress, queue, settings) +├── backups/ # automatic backups before schema upgrades +├── provider_health.json └── search_cache/ ``` -`library/`, the database, cache, and `.env` are excluded from Git. - ## Architecture ```text backend/ -├── api/ # FastAPI endpoints -├── providers/ # adapters over anicli-api -├── downloader/ # durable SQLite queue and ffmpeg downloads -├── player/ # HLS/media reverse proxy -├── services/ # library, settings, ffmpeg helpers -├── database/ # async SQLAlchemy + SQLite -├── models/ # persistence models -└── schemas/ # Pydantic API contracts - +├── api/ # FastAPI routers: catalog, downloads, library, history, system +├── providers/ # provider engine over anicli-api: registry, players, health, cache, HTTP sessions +├── player/ # media sessions + HLS-rewriting media proxy +├── downloader/ # durable queue, concurrency, validation, atomic finalisation +├── services/ # library files/index, settings, ffmpeg +├── database/ # async SQLAlchemy + versioned migrations +├── models/ schemas/ frontend/src/ -├── api/ # typed API client -├── components/ # shared UI -└── pages/ # search, library, anime, player, downloads, settings +├── api/ lib/ components/ pages/ pages/player/ ``` -Backend: Python, FastAPI, async SQLAlchemy, SQLite, Pydantic, and httpx. Frontend: React, TypeScript, Tailwind CSS, Vite, and hls.js. +Backend: Python, FastAPI, async SQLAlchemy, SQLite, Pydantic, httpx. Frontend: React, TypeScript, Tailwind CSS, Vite, +hls.js. -## Verification +## Development checks ```bash -# Backend -.venv/Scripts/python.exe -m pytest -q # Windows -.venv/bin/python -m pytest -q # macOS/Linux - -# Frontend -cd frontend -npm run lint +python -m pytest -q # backend (use the .venv interpreter) +cd frontend && npm run check # type-check, lint, unit tests npm run build ``` -For a runtime check, start AnimeBox, search for a title, choose a season voiceover, open an episode, download it, and then play it from **Library**. +Manual tests against real sources: [docs/TESTING.md](docs/TESTING.md). Releases: [docs/RELEASING.md](docs/RELEASING.md). ## Troubleshooting -### `venv not found` - -Run `scripts/install.ps1` or `scripts/install.sh`. If the environment was moved between directories or computers, delete `.venv` and install again. - -### Search works, but the stream does not open - -- try another voiceover or source; -- check whether the source website is available in your region; -- configure `HTTP_PROXY` in `.env` or the interface; -- inspect backend messages in the terminal. - -### `Search cache expired` +**A stream does not open.** The player tries every player of the chosen voiceover and shows which ones failed. Pick +another voiceover, or check **Settings → Sources → Check** to see which stage fails (search, title, episodes, +voiceovers, stream). -Search for the title again and open it from the new results. AnimeBox does not permanently store links to source pages. +**A source shows "not responding".** The site may be unreachable from your network or may have changed. Results from +other sources are still shown. If a source works only through a proxy, set it in **Settings → Network** — it is then +used for search, playback and downloads alike (some video hosts tie links to your IP address). -### Downloads require FFmpeg +**"Title data is outdated".** The source no longer recognises the stored link; search for the title again. -When running from source, install FFmpeg and confirm that `ffmpeg -version` works in a new terminal. HLS/DASH downloads and Steam Deck optimization are unavailable without FFmpeg. Portable archives already include FFmpeg. +**Downloads fail with an ffmpeg error.** From source: install FFmpeg and check `ffmpeg -version`. Portable builds +include FFmpeg; **Settings → Diagnostics** shows which binary is used. -### A source suddenly stopped working +**The library shows fewer episodes than before.** Episodes whose files were deleted or moved are hidden. Use +**Library → Rescan**. -The provider may have changed its HTML/API or may be unavailable through your internet provider. AnimeBox waits no longer than `PROVIDER_SEARCH_TIMEOUT` for each source and returns results from available providers. If only one domain fails, the portable build is not necessarily broken—it may be a routing issue or source-specific block. Configure an HTTP/SOCKS proxy in Settings when needed. Do not edit `.references/anicli-api` manually: recreate the checkout with the installation script and keep compatibility changes in `patches/`. +**Logs.** Portable builds write `data/animebox.log`; from source, logs go to the terminal. Proxy passwords and signed +link tokens are redacted. ## Legal notice -AnimeBox is a local client and does not host media content. Use it only with content and sources permitted by applicable law and service terms. The user is responsible for choosing sources and saving files. +AnimeBox is a local client and does not host any media. Use it only with sources and content permitted by applicable +law and the sources' terms. You are responsible for the sources you choose and the files you save. ## Credits -AnimeBox uses or studies work from: +AnimeBox uses and is inspired by: -- [anicli-api](https://github.com/vypivshiy/anicli-api) — catalog, episode, voiceover, and stream extraction; -- [ani-cli-ru](https://github.com/vypivshiy/ani-cli-ru) — UX and reverse-proxy approach; -- [HakuNeko](https://github.com/manga-download/hakuneko) — connector and downloader architecture; -- [Sonarr](https://github.com/Sonarr/Sonarr) — ideas for durable queues, imports, and media libraries. +- [anicli-api](https://github.com/vypivshiy/anicli-api) — search, titles, episodes, voiceovers and stream extraction; +- [ani-cli-ru](https://github.com/vypivshiy/ani-cli-ru) — interaction flow and the HLS reverse-proxy approach; +- [HakuNeko](https://github.com/manga-download/hakuneko) — connector registry and download manager architecture; +- [Sonarr](https://github.com/Sonarr/Sonarr) — durable queues, failed-download handling and library lifecycle ideas. -Detailed licenses and reuse boundaries: [THIRD_PARTY.md](THIRD_PARTY.md). +With gratitude to the authors of the original projects. Licenses and reuse details: [THIRD_PARTY.md](THIRD_PARTY.md). diff --git a/README.ru.md b/README.ru.md index 688de33..f23b8e2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -2,231 +2,219 @@ [English](README.md) | **Русский** -Локальная аниме-библиотека для поиска, просмотра и поездок без интернета. AnimeBox запускается на вашем компьютере, сохраняет серии в обычные файлы и не требует аккаунта или облачного сервиса. +Локальная библиотека аниме для русскоязычных сайтов. Ищите тайтлы, выбирайте озвучку, смотрите в браузере или +скачивайте серии, чтобы смотреть без интернета. AnimeBox работает на вашем компьютере, хранит серии обычными файлами и +не требует аккаунтов и облака. -> **Статус:** ранний MVP. В проекте пока достаточно много ошибок, ведётся активная работа над их исправлением и дальнейшим развитием. Сайты-источники меняются без предупреждения, поэтому отдельные провайдеры могут временно переставать работать. +> Сайты-источники меняются без предупреждения. Рекомендуемый и лучше всего проверенный источник — AnimeGO; остальные +> помечены как экспериментальные. Реальное состояние каждого источника видно в **Настройки → Источники**. -## Что умеет +## Возможности -| Возможность | Статус | +| | | |---|---| -| Поиск по нескольким источникам | ✅ | -| Карточка тайтла, серии и озвучки | ✅ | -| Онлайн-просмотр HLS через локальный proxy | ✅ | -| Загрузка серии или сезона | 🟨 | -| Очередь, прогресс, отмена и повтор | ✅ | -| Локальная библиотека и офлайн-плеер | ✅ | -| Сохранение прогресса просмотра | ✅ | -| Оптимизация под Steam Deck | ✅ | +| Поиск | Несколько источников параллельно; медленный или сломанный источник не блокирует остальные, ответ каждого виден | +| Страница тайтла | Описание, серии, озвучки с их плеерами, прогресс просмотра и офлайн-статус каждой серии | +| Просмотр | Встроенный плеер: сохранение и восстановление позиции, горячие клавиши, качество, скорость, смена озвучки, аудиодорожки и субтитры (если они есть в потоке), автопереход к следующей серии | +| Надёжность | Если один плеер озвучки не работает (например, CVH), пробуется следующий (Kodik, Aniboom, …); устаревший поток переполучается без потери позиции | +| Загрузки | Серия или весь сезон, параллельная очередь, реальные прогресс и скорость, повторы, отмена; пауза и продолжение для прямых MP4 | +| Библиотека | Только серии, которые реально есть на диске; занятое место, сортировка, фильтр, удаление, пересканирование | +| Офлайн | Скачанные серии, постеры и прогресс работают без интернета, даже если источник недоступен | +| Steam Deck | Linux-сборка запускается на SteamOS; по желанию — сжатие в H.265 для экономии места | -## Интерфейс +### Источники -AnimeBox использует тёмный кинематографичный интерфейс с фиолетовым акцентом. После запуска откройте `http://127.0.0.1:8787`. На странице тайтла выберите **Озвучку сезона** — она применяется к просмотру и загрузке всех серий. При необходимости можно открыть список озвучек конкретной серии и задать для неё отдельное исключение. Режим **Автовыбор** использует приоритет озвучек из настроек и выбирает первую доступную, если предпочтительных вариантов нет. +| Источник | Статус | Примечание | +|---|---|---| +| AnimeGO | **Стабильный**, по умолчанию | Много озвучек; у каждой несколько плееров с автоматическим переключением | +| AniLiberty (AniLibria) | Экспериментальный | Только собственная озвучка | +| AnimeVost | Экспериментальный | Только собственная озвучка; прямые MP4 (загрузку можно ставить на паузу) | +| Yummy Anime | Экспериментальный | Часть плееров — только DASH: можно скачать, но не смотреть в браузере | +| AniLib, DreamCast, SameBand | Экспериментальные, выключены по умолчанию | Небольшие или зависящие от региона каталоги | -> **Рекомендация по загрузке:** автор рекомендует выбирать **AnimeGO**, когда источник доступен, поскольку сейчас он работает лучше всего и обеспечивает наиболее стабильные загрузки. +HDRezka больше не предлагается: её экстрактор обходит антибот-проверку. AnimeBox не обходит CAPTCHA и другие +ограничения доступа. -Кнопка **Выключить** в шапке корректно останавливает локальный сервер и освобождает порт `8787`. Простое закрытие вкладки браузера сервер не завершает: это защищает активные загрузки от случайного закрытия окна. +## Установка (portable, рекомендуется) -## Как устроен portable build - -В архиве нет двух отдельных приложений. `AnimeBox.exe`/`AnimeBox` — один Python launcher с FastAPI backend и встроенными статическими файлами уже собранного React/TypeScript frontend. Node.js в архив не входит и на устройстве пользователя не запускается: браузер получает обычные HTML/CSS/JavaScript файлы от локального backend. - -Каталог `.references/` содержит только локальные checkout исходных open-source проектов и не публикуется в Git. Скрипты установки и CI клонируют `anicli-api`, затем применяют отслеживаемый compatibility patch из `patches/`. Поэтому обязательные изменения больше не прячутся внутри полностью игнорируемого checkout. - -## Portable Releases - -Рекомендуемый способ для обычного пользователя — скачать готовый архив своей платформы из GitHub Releases. Python, Node.js, Git и системный ffmpeg не нужны: Python runtime, backend, собранный frontend, `anicli-api` и ffmpeg уже включены. - -AnimeBox остаётся web-приложением: launcher поднимает приватный сервер на `http://127.0.0.1:8787` и открывает системный браузер. Отдельный desktop-интерфейс не устанавливается. +Скачайте архив для своей системы в [GitHub Releases](https://github.com/endercodezz/AnimeBox/releases). +Python, Node.js и FFmpeg уже внутри — больше ничего ставить не нужно. | Архив | Запуск | |---|---| -| `AnimeBox-Windows-x64.zip` | распаковать и открыть `AnimeBox.exe` | -| `AnimeBox-Linux-x64.tar.gz` | Linux x86_64, включая Steam Deck/SteamOS; распаковать, выполнить `chmod +x AnimeBox && ./AnimeBox` | -| `AnimeBox-macOS-x64.zip` | Intel Mac; распаковать и открыть launcher | -| `AnimeBox-macOS-arm64.zip` | Apple Silicon; распаковать и открыть launcher | +| `AnimeBox-<версия>-Windows-x64.zip` | Распакуйте, откройте `AnimeBox.exe` | +| `AnimeBox-<версия>-Linux-x64.tar.gz` | Linux x86_64 и Steam Deck: распакуйте, затем `./AnimeBox` | +| `AnimeBox-<версия>-macOS-x64.zip` | Mac на Intel: распакуйте, откройте `AnimeBox` | +| `AnimeBox-<версия>-macOS-arm64.zip` | Apple Silicon: распакуйте, откройте `AnimeBox` | -Portable-папка уже содержит базовый `.env`, `data/`, `library/`, bundled ffmpeg и все runtime-зависимости. Windows и Linux/Steam Deck архивы используют GPL-сборки FFmpeg от BtbN; macOS архивы содержат FFmpeg вместе с перенесёнными библиотеками Homebrew, поэтому Homebrew на Mac пользователя не нужен. Интернет нужен только для поиска, получения новых потоков и загрузки. Уже скачанная библиотека, локальные постеры, MP4 и прогресс просмотра работают офлайн; внешние web-шрифты не используются. +AnimeBox запускает приватный сервер на `http://127.0.0.1:8787` и открывает браузер. Закрытие вкладки его не +останавливает — используйте кнопку **Остановить** в боковой панели, чтобы случайно не прервать загрузки. -При обновлении сохраните и перенесите в новую portable-папку: +Проверка архива по контрольным суммам: `sha256sum -c SHA256SUMS.txt --ignore-missing`. -- `.env` — настройки; -- `data/` — база, история и прогресс; -- `library/` — скачанные серии и постеры. +**macOS:** неподписанной сборке при первом запуске может понадобиться **Control-клик → Открыть**. Если загрузки +падают с `ffmpeg failed`, выполните в папке AnimeBox `bash scripts/grant-macos-permissions.sh` (флаг +`--clear-quarantine` — только для архивов из источника, которому вы доверяете). -На неподписанном macOS build при первом запуске может потребоваться **Control-click → Open**. Portable archive нужно полностью распаковать перед запуском. Если при скачивании появляется `ffmpeg failed`, откройте Terminal в распакованной папке AnimeBox и выполните: +## Обновление с 1.x -```bash -bash scripts/grant-macos-permissions.sh -``` +Ваши данные лежат в трёх местах, остальное хранить не нужно: -Если Gatekeeper всё ещё блокирует FFmpeg и архив получен из доверенного источника, явно удалите quarantine только с файлов AnimeBox/FFmpeg: +- `.env` — настройки (файлы из 1.x работают без изменений); +- `data/` — база данных (история, прогресс, история загрузок, настройки); +- `library/` — скачанные серии и постеры. -```bash -bash scripts/grant-macos-permissions.sh --clear-quarantine -``` +**Portable:** распакуйте новую версию и перенесите эти три элемента из старой папки в новую (с заменой пустых). +**Из исходников:** `git pull`, затем снова запустите скрипт установки. -Опция не отключает Gatekeeper во всей системе и не изменяет посторонние файлы. +При первом запуске AnimeBox 2.0: -## Быстрый старт из исходников +1. копирует базу в `data/backups/animebox-v0-<дата>.db`; +2. объединяет дубли, появившиеся из-за ошибки с идентификаторами в 1.x, сохраняя лучший прогресс; +3. переводит сохранённые пути в относительные к библиотеке; +4. перестаёт считать просто открытые тайтлы содержимым библиотеки (файлы не удаляются); +5. удаляет пустые папки и брошенные файлы `*.part.*`, оставленные в `library/` версией 1.x. -Для запуска из исходников нужны Python 3.12+, Node.js 20+ с npm, Git и ffmpeg в `PATH`. Docker не требуется. Install-скрипт сам скачивает официальный `anicli-api` (MIT) в `.references/anicli-api`; остальные reference-проекты нужны только разработчикам. +Если что-то пойдёт не так, миграция откатывается, а копия остаётся в `data/backups/`. Серии, которые есть в базе, но +отсутствуют на диске, скрываются из библиотеки; **Библиотека → Пересканировать** удаляет такие записи и находит серии, +скачанные AnimeBox ранее, но выпавшие из учёта. -### Windows 11 +## Запуск из исходников + +Нужны Python 3.12+, Node.js 24+, Git и FFmpeg в `PATH`. Docker не используется. Скрипт установки скачивает +закреплённый проверенный коммит [anicli-api](https://github.com/vypivshiy/anicli-api) в `.references/anicli-api` и +применяет патч совместимости из `patches/`. ```powershell +# Windows git clone https://github.com/endercodezz/AnimeBox.git cd AnimeBox .\scripts\install.ps1 .\scripts\run.ps1 ``` -Можно запустить двойным кликом: сначала `scripts\install.cmd`, затем `scripts\run.cmd`. Диагностика: `scripts\check.cmd`. - -При первом `install` или `run` автоматически создаётся базовый `.env` из `.env.example`. Существующий `.env` никогда не перезаписывается. - -### macOS / Linux / Steam Deck - ```bash +# macOS / Linux / Steam Deck git clone https://github.com/endercodezz/AnimeBox.git cd AnimeBox -chmod +x scripts/*.sh ./scripts/install.sh ./scripts/run.sh ``` -### Режим разработки - -```powershell -# Windows -.\scripts\run.ps1 -Dev -``` - -```bash -# macOS / Linux -./scripts/run.sh --dev -``` - -- Production UI + API: `http://127.0.0.1:8787` -- Vite UI в dev-режиме: `http://127.0.0.1:5173` -- OpenAPI: `http://127.0.0.1:8787/docs` - -`run` собирает frontend, запускает API, ждёт успешный health-check и только затем открывает браузер. Используйте `--skip-build` / `-SkipBuild`, чтобы оставить существующую сборку, и `--no-browser` / `-NoBrowser`, чтобы не открывать браузер. - -Проверить установку без запуска загрузки: - -```powershell -.\scripts\check.ps1 -``` - -```bash -./scripts/check.sh -``` +В Windows можно запускать двойным щелчком: `scripts\install.cmd`, `scripts\run.cmd`, `scripts\check.cmd`. +Режим разработки с горячей перезагрузкой: `run.ps1 -Dev` / `run.sh --dev` (интерфейс на `http://127.0.0.1:5173`, +документация API — `http://127.0.0.1:8787/docs`). ## Настройка -При первом запуске `.env.example` копируется в `.env`. Основные параметры: +`.env` создаётся из `.env.example` при первом запуске и никогда не перезаписывается. Большинство параметров есть и в +**Настройках** (значения из интерфейса важнее). Основное: -- `LIBRARY_PATH` — каталог загруженных серий; -- `DATABASE_URL` — SQLite URL; -- `HTTP_PROXY` — необязательный HTTP/SOCKS proxy для ограниченных источников; -- `PROVIDER_SEARCH_TIMEOUT` — максимальное время ожидания каждого поискового провайдера, по умолчанию `10` секунд; медленные или заблокированные источники пропускаются, а результаты остальных возвращаются; -- `PREFERRED_VOICEOVERS` — приоритет озвучек через запятую для автоматического выбора; -- `DEFAULT_QUALITY` — желаемое качество; -- `STEAM_DECK_CRF`, `STEAM_DECK_HEIGHT` — параметры перекодирования. +| Параметр | Назначение | +|---|---| +| `LIBRARY_PATH`, `DATA_PATH` | Где лежат серии и база (относительно папки AnimeBox) | +| `HTTP_PROXY` | HTTP- или SOCKS-прокси для поиска, просмотра **и** загрузок | +| `PROVIDER_SEARCH_TIMEOUT`, `PROVIDER_DETAIL_TIMEOUT`, `PROVIDER_STREAM_TIMEOUT` | Лимиты времени на этапы для каждого источника | +| `PREFERRED_VOICEOVERS` | Приоритет озвучек для автовыбора | +| `DEFAULT_QUALITY` | 360, 480, 720 или 1080 — ближайшее доступное качество не выше указанного | +| `MAX_CONCURRENT_DOWNLOADS` | Одновременных загрузок (1–6) | +| `ALLOWED_HOSTS` | Дополнительные имена хоста помимо `127.0.0.1`/`localhost` | -Большинство пользовательских параметров также доступны на странице **Настройки**. +## Горячие клавиши плеера + +| Клавиша | Действие | +|---|---| +| Пробел / K | Пауза / воспроизведение | +| ← / → | −5 с / +5 с | +| J / L | −10 с / +10 с | +| ↑ / ↓ | Громкость | +| M | Без звука | +| F | Полный экран | +| Shift+N / Shift+P | Следующая / предыдущая серия | +| < / > | Медленнее / быстрее | +| 0–9 | Переход к 0–90 % | +| / или Ctrl+K | Поиск (вне плеера) | + +Клавиши работают и в русской раскладке. ## Где хранятся данные ```text library/ -└── Anime Name/ +└── Название аниме/ ├── poster.jpg - ├── metadata.json + ├── metadata.json # содержит идентификатор AnimeBox — нужен для пересканирования └── Season 1/ ├── Episode 01.mp4 └── Episode 02.mp4 - data/ -├── animebox.db +├── animebox.db # SQLite: история, прогресс, очередь, настройки +├── backups/ # автоматические копии перед обновлением схемы +├── provider_health.json └── search_cache/ ``` -`library/`, база, cache и `.env` исключены из Git. - ## Архитектура ```text backend/ -├── api/ # FastAPI endpoints -├── providers/ # adapters over anicli-api -├── downloader/ # durable SQLite queue and ffmpeg downloads -├── player/ # HLS/media reverse proxy -├── services/ # library, settings, ffmpeg helpers -├── database/ # async SQLAlchemy + SQLite -├── models/ # persistence models -└── schemas/ # Pydantic API contracts - +├── api/ # FastAPI-роутеры: каталог, загрузки, библиотека, история, система +├── providers/ # движок источников над anicli-api: реестр, плееры, здоровье, кэш, HTTP-сессии +├── player/ # медиасессии и прокси с переписыванием HLS +├── downloader/ # очередь, параллельность, проверка, атомарное завершение +├── services/ # файлы и индекс библиотеки, настройки, ffmpeg +├── database/ # async SQLAlchemy и версионные миграции +├── models/ schemas/ frontend/src/ -├── api/ # typed API client -├── components/ # shared UI -└── pages/ # search, library, anime, player, downloads, settings +├── api/ lib/ components/ pages/ pages/player/ ``` -Backend: Python, FastAPI, async SQLAlchemy, SQLite, Pydantic, httpx. Frontend: React, TypeScript, Tailwind CSS, Vite, hls.js. +Бэкенд: Python, FastAPI, async SQLAlchemy, SQLite, Pydantic, httpx. Фронтенд: React, TypeScript, Tailwind CSS, Vite, +hls.js. -## Проверки +## Проверки для разработчиков ```bash -# Backend -.venv/Scripts/python.exe -m pytest -q # Windows -.venv/bin/python -m pytest -q # macOS/Linux - -# Frontend -cd frontend -npm run lint +python -m pytest -q # бэкенд (интерпретатор из .venv) +cd frontend && npm run check # типы, линтер, юнит-тесты npm run build ``` -Для runtime-проверки запустите AnimeBox, найдите тайтл, выберите озвучку сезона, откройте серию, загрузите её и затем воспроизведите через раздел **Библиотека**. +Ручные тесты с реальными источниками — [docs/TESTING.md](docs/TESTING.md). Выпуск релизов — [docs/RELEASING.md](docs/RELEASING.md). ## Решение проблем -### `venv not found` - -Запустите `scripts/install.ps1` или `scripts/install.sh`. Если окружение перенесли между каталогами или компьютерами, удалите `.venv` и повторите установку. - -### Поиск работает, но поток не открывается - -- попробуйте другую озвучку или источник; -- проверьте доступность сайта в вашем регионе; -- задайте `HTTP_PROXY` в `.env` или интерфейсе; -- изучите сообщения backend в терминале. - -### `Search cache expired` +**Поток не открывается.** Плеер перебирает все плееры выбранной озвучки и показывает, какие не сработали. Выберите +другую озвучку или откройте **Настройки → Источники → Проверить**, чтобы увидеть, на каком этапе сбой (поиск, тайтл, +серии, озвучки, поток). -Повторите поиск тайтла и откройте его из новых результатов. AnimeBox не хранит вечные ссылки на страницы источников. +**Источник «не отвечает».** Сайт может быть недоступен из вашей сети или изменился. Результаты остальных источников +всё равно показываются. Если источник работает только через прокси, укажите его в **Настройки → Сеть** — он будет +использоваться и для поиска, и для просмотра, и для загрузок (некоторые видеохостинги привязывают ссылки к IP). -### Загрузка требует ffmpeg +**«Данные тайтла устарели».** Источник больше не узнаёт сохранённую ссылку — найдите тайтл поиском заново. -При запуске из исходников установите ffmpeg и убедитесь, что команда `ffmpeg -version` работает в новом терминале. HLS/DASH и оптимизация Steam Deck без ffmpeg недоступны. Portable archives уже содержат ffmpeg. +**Загрузки падают с ошибкой ffmpeg.** При запуске из исходников установите FFmpeg и проверьте `ffmpeg -version`. В +portable-сборках FFmpeg уже есть; **Настройки → Диагностика** показывает, какой бинарник используется. -### Источник внезапно перестал работать +**В библиотеке меньше серий, чем раньше.** Серии с удалёнными или перемещёнными файлами скрываются. Используйте +**Библиотека → Пересканировать**. -Провайдер мог изменить HTML/API либо быть недоступен у вашего интернет-провайдера. AnimeBox ждёт каждый источник не более `PROVIDER_SEARCH_TIMEOUT` секунд и возвращает результаты доступных провайдеров. Если проблемы возникают только для одного домена, portable build не сломан — это сетевой маршрут или блокировка конкретного источника. При необходимости задайте HTTP/SOCKS proxy в настройках. Не редактируйте `.references/anicli-api` вручную: пересоздайте checkout через скрипт установки, а compatibility-изменения храните в `patches/`. +**Логи.** Portable-сборка пишет `data/animebox.log`; при запуске из исходников логи идут в терминал. Пароли прокси и +подписи в ссылках маскируются. ## Правовые сведения -AnimeBox — локальный клиент и не размещает медиаконтент. Используйте приложение только для контента и источников, доступ к которым разрешён применимым законодательством и правилами сервиса. Пользователь отвечает за выбор источников и сохранение файлов. +AnimeBox — локальный клиент, он не размещает медиаконтент. Используйте его только с источниками и материалами, +разрешёнными законом и условиями сервисов. Ответственность за выбор источников и сохранённые файлы несёт пользователь. ## Credits -AnimeBox использует или изучает наработки: +AnimeBox использует и вдохновляется: -- [anicli-api](https://github.com/vypivshiy/anicli-api) — извлечение каталога, серий, озвучек и потоков; -- [ani-cli-ru](https://github.com/vypivshiy/ani-cli-ru) — UX и reverse-proxy подход; -- [HakuNeko](https://github.com/manga-download/hakuneko) — архитектура коннекторов и загрузок; -- [Sonarr](https://github.com/Sonarr/Sonarr) — идеи устойчивой очереди, импорта и медиатеки. +- [anicli-api](https://github.com/vypivshiy/anicli-api) — поиск, тайтлы, серии, озвучки и получение потоков; +- [ani-cli-ru](https://github.com/vypivshiy/ani-cli-ru) — сценарий взаимодействия и подход к обратному прокси для HLS; +- [HakuNeko](https://github.com/manga-download/hakuneko) — архитектура коннекторов и менеджера загрузок; +- [Sonarr](https://github.com/Sonarr/Sonarr) — идеи надёжной очереди, обработки неудачных загрузок и жизненного цикла библиотеки. -Подробные лицензии и границы переиспользования: [THIRD_PARTY.md](THIRD_PARTY.md). +С благодарностью авторам оригинальных проектов. Лицензии и границы заимствований — [THIRD_PARTY.md](THIRD_PARTY.md). diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index efd1ef0..771ca4f 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -1,51 +1,79 @@ # Third-party software -AnimeBox uses and is inspired by the following open-source projects. -Attribution notices are preserved where code patterns or libraries are reused. -Reference repositories live in local `.references/` checkouts and are excluded from AnimeBox release archives. +AnimeBox uses and is inspired by the following open-source projects. Attribution notices are preserved in source +comments where code is adapted. Reference repositories live in local `.references/` checkouts, are not committed, and +are not included in release archives (except the installed `anicli-api` package itself). ## anicli-api - **Repository:** https://github.com/vypivshiy/anicli-api - **Author:** vypivshiy - **License:** MIT -- **Used for:** Search, title metadata, episodes, voiceovers/sources, and stream URL extraction (AnimeGo, AniLiberty, AnimeVost, Yummy Anime, and other extractors). Installed as an editable dependency from `.references/anicli-api` and wrapped by `backend/providers/`. +- **Used for:** search, title metadata, episodes, voiceovers/sources and stream extraction (AnimeGO, AniLiberty, + AnimeVost, Yummy Anime, and other extractors). Installed from a pinned commit (`patches/anicli-api.ref`) with a small + tracked compatibility patch (`patches/`), and wrapped by `backend/providers/`. +- **Adapted code:** `backend/providers/players.py` contains an adapter derived from anicli-api's `CdnVideoHub` + decoder (for AnimeGO's current CVH iframe URLs) and an awaited variant of the Yummy Anime CVH flow. Both are marked + with attribution comments. The anicli-api package is bundled into portable builds under its MIT license. ## ani-cli-ru - **Repository:** https://github.com/vypivshiy/ani-cli-ru - **Author:** vypivshiy - **License:** GPL-3.0 -- **Used for:** Interaction flow ideas (search → episode → voiceover → play), voiceover preference ordering, and reverse-proxy implementation for browser playback (`backend/player/proxy.py`). HLS URL-rewriting logic is adapted under GPL-3.0 with attribution comments in source. +- **Used for:** interaction-flow ideas (search → episode → voiceover → play), voiceover preference ordering, and the + approach of routing HLS playlist entries through a local reverse proxy. AnimeBox 1.x adapted ani-cli-ru's HLS + rewriting code; AnimeBox 2.0 replaced it with its own implementation (`backend/player/proxy.py`, + `backend/player/sessions.py`) based on server-issued session tokens. No ani-cli-ru source code is included in 2.0. -## Hakuneko +## HakuNeko - **Repository:** https://github.com/manga-download/hakuneko - **Organization:** manga-download / HakuNeko - **License:** Unlicense (public domain dedication) -- **Used for:** Architecture ideas — connector registry, download job queue, settings persistence, and library folder layout. Inspiration only; no JavaScript connectors were ported. +- **Used for:** architecture ideas — connector registry, download job queue, settings persistence and library folder + layout. Inspiration only; no JavaScript connectors were ported. ## Sonarr - **Repository:** https://github.com/Sonarr/Sonarr - **Organization:** Sonarr / Servarr contributors - **License:** GPL-3.0 -- **Used for:** Architecture ideas for durable download queues, failed-download handling, media imports, naming, and library lifecycle. Inspiration only; no C# source files were copied. +- **Used for:** architecture ideas for durable download queues, failed-download handling, validation before import, + naming and library lifecycle. Inspiration only; no C# source files were copied. ## FFmpeg - **Website:** https://ffmpeg.org/ - **Project:** FFmpeg contributors -- **License:** LGPL-2.1-or-later or GPL-2.0-or-later depending on build configuration -- **Used for:** HLS/DASH downloading, remuxing, and optional Steam Deck transcoding. Windows and Linux/Steam Deck archives bundle GPL builds from [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds); macOS archives bundle Homebrew FFmpeg and its recursively collected, relocated dynamic libraries. +- **License:** LGPL-2.1-or-later or GPL-2.0-or-later depending on build configuration (bundled builds are GPL) +- **Used for:** HLS/DASH downloading, remuxing, validation and optional Steam Deck transcoding. Windows and + Linux/Steam Deck archives bundle unmodified GPL builds from [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) + (which publishes sources and build scripts); macOS archives bundle Homebrew FFmpeg with its relocated dynamic + libraries. Each archive contains `tools/FFMPEG-NOTICE.txt` and, where the upstream package provides it, the FFmpeg + license file. FFmpeg runs as a separate program; AnimeBox does not link against it. ## PyInstaller - **Repository:** https://github.com/pyinstaller/pyinstaller -- **Organization:** PyInstaller Development Team -- **License:** GPL-2.0-or-later with a special exception permitting distribution of bundled applications -- **Used for:** Packaging Python runtime and AnimeBox backend into native portable launchers. +- **License:** GPL-2.0-or-later with a bootloader exception permitting distribution of bundled applications +- **Used for:** packaging the Python runtime and backend into portable launchers. + +## Frontend libraries (bundled into the web UI) + +| Project | License | Used for | +|---|---|---| +| [React](https://github.com/facebook/react), [React Router](https://github.com/remix-run/react-router) | MIT | UI framework and routing | +| [hls.js](https://github.com/video-dev/hls.js) | Apache-2.0 | HLS playback in the browser | +| [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) | MIT | Styling (build-time) | +| [Unbounded](https://github.com/googlefonts/unbounded) via [Fontsource](https://fontsource.org/) | SIL Open Font License 1.1 | Display typeface | +| [Manrope](https://github.com/sharanda/manrope) via [Fontsource](https://fontsource.org/) | SIL Open Font License 1.1 | Body typeface | + +## Python libraries (bundled into portable builds) + +FastAPI, Pydantic, SQLAlchemy, aiosqlite, h2, attrs (MIT); Starlette, Uvicorn, httpx, httpcore (BSD-3-Clause). Their license files ship inside the bundled package metadata. --- -When modifying vendor references under `.references/`, keep their original LICENSE files intact. +When modifying vendor references under `.references/`, keep their original LICENSE files intact and put AnimeBox +changes in tracked patches under `patches/`. 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/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..a58e131 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,58 @@ +# Releasing AnimeBox + +Releases are built and published by `.github/workflows/release.yml`. + +## Version + +The version lives in two places that CI checks are equal: + +- `backend/__init__.py` → `__version__ = "2.0.0"` +- `frontend/package.json` → `"version": "2.0.0"` + +Prereleases use a semver suffix in **both** places and in the tag, e.g. `2.0.1-beta.1` / `v2.0.1-beta.1`. + +## Steps + +1. Update the version in both files. +2. Add a `## [X.Y.Z] — YYYY-MM-DD` section to `CHANGELOG.md` (the workflow refuses to publish without it). +3. Run the checks locally and the manual tests in [TESTING.md](TESTING.md). +4. Commit, then tag and push: + + ```bash + git tag v2.0.0 + git push origin main v2.0.0 + ``` + +## What the workflow does + +1. **checks** (`ci.yml`): backend tests, frontend type-check/lint/tests/build, version consistency, no agent files tracked. +2. **meta**: verifies the tag equals the code version; tags with `-` become prereleases. +3. **build** (Windows x64, Linux x64 on Ubuntu 22.04 for SteamOS compatibility, macOS x64, macOS arm64): + PyInstaller one-folder build with the compiled UI, bundled FFmpeg (+ license notice), archive + `AnimeBox-<version>-<platform>.zip|tar.gz` with a `.sha256`, content verification (`release/verify_archive.py`), + packaged-app smoke test, and an informational live AnimeGO check (does not block the release — sites can be down). +4. **publish** (tags only): refuses to publish unless all four archives exist and their checksums verify, writes + `SHA256SUMS.txt`, and creates the GitHub Release with notes from `CHANGELOG.md` plus generated notes. + +`workflow_dispatch` runs everything except publishing; the archives are available as workflow artifacts. + +## Building locally + +```bash +python -m pip install -r backend/requirements.txt -r release/requirements-build.txt +python release/build_portable.py --ffmpeg /path/to/ffmpeg +python release/verify_archive.py release/artifacts/AnimeBox-*.zip # or .tar.gz on Linux +``` + +Builds are native: build each platform on that platform. + +## Signing + +Artifacts are unsigned unless signing is configured. macOS users may need Control-click → Open; Windows SmartScreen may +warn on first launch. To sign, add certificates as repository secrets and a signing step before archiving; never put +secrets in the workflow file. + +## Requirements and permissions + +- The repository's Actions must be allowed to create releases (`contents: write` is requested only by the publish job). +- No extra secrets are required: the default `GITHUB_TOKEN` is used. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..f1cf580 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,88 @@ +# Testing AnimeBox + +## Automated tests (CI) + +CI never talks to real anime sites: provider, stream and media-proxy tests use deterministic fakes and in-memory HTTP +transports, so a site being down cannot break the build. + +```bash +python -m pytest -q # backend: library integrity, migrations, media proxy security, provider engine, + # download queue, playback progress, API errors, ffmpeg handling, portable launcher +cd frontend +npm run check # tsc + oxlint + vitest +npm run build +``` + +| Area | Tests | +|---|---| +| Empty-library regression, file detection, missing/partial files, delete, rescan | `tests/test_library.py` | +| 1.x database upgrade, backups, rollback, repeatability, constraints | `tests/test_migrations.py` | +| SSRF protection, session scoping, header isolation, redirects, playlist rewriting | `tests/test_media_proxy.py` | +| Provider timeouts/failures, player fallback, exact cache recovery, health status | `tests/test_provider_registry.py` | +| Queue rules, validation, atomic finalisation, retries, recovery, concurrency, Range resume | `tests/test_downloads.py` | +| Progress persistence/restoration, error mapping, settings validation, Host protection | `tests/test_api.py` | +| ffmpeg discovery, progress parsing, process cleanup | `tests/test_ffmpeg.py` | +| UI utilities, episode grid accessibility, API client errors | `frontend/src/**/*.test.ts(x)` | + +The release workflow additionally smoke-tests every packaged build (startup, bundled UI, providers, empty library on a +fresh install, ffmpeg detection, Host protection, clean shutdown with the WAL checkpointed). + +## Manual integration tests with real sources + +Run these before tagging a release and after bumping `patches/anicli-api.ref`. Use a throwaway data folder so your +own library is not touched: + +```bash +# from the repository root +export DATA_PATH=./.tmp/manual/data LIBRARY_PATH=./.tmp/manual/library \ + DATABASE_URL=sqlite+aiosqlite:///./.tmp/manual/data/animebox.db PORT=8799 +python -m backend.main +``` + +(PowerShell: `$env:DATA_PATH='./.tmp/manual/data'` etc.) Then open `http://127.0.0.1:8799`. + +### 1. Provider diagnostics + +**Settings → Sources → Check** for AnimeGO. Expected: all five stages (Search, Title, Episodes, Voiceovers, Stream) +green, status "Работает". Repeat for each enabled experimental source and note failures in the release notes. + +Command-line equivalent: + +```bash +python -c "import asyncio; from backend.providers.registry import ProviderRegistry as R; r=R(); asyncio.run(r.check('animego')); print({k: (v.ok, v.error) for k, v in r.health.get('animego').stages.items()})" +``` + +### 2. AnimeGO end to end + +1. Search «Врата Штейна». Expected: results from AnimeGO plus a status chip per source. +2. Open the title. Expected: description, episode grid; **no new folder appears in `library/`** and the Library page + stays empty. +3. Pick episode 1 and a voiceover with several players (e.g. AniLibria). Click **Смотреть онлайн**. Expected: playback + starts; the subtitle line shows the voiceover and the player that worked. +4. Seek to ~5:00, go back to the title page, open the episode again. Expected: playback resumes near 5:00. +5. In the player, switch voiceover and quality. Expected: playback continues near the same position. +6. Press `Shift+N` on the last episode. Expected: nothing happens (no next episode). + +### 3. Downloads + +1. Download one episode. Expected: progress rises with real speed and size; status «Готово»; the title appears in the + Library with one episode; `library/<Title>/Season 1/Episode 01.mp4`, `poster.*` and `metadata.json` exist; no + `*.part.*` remains. +2. Play it from the Library with the network disconnected. Expected: plays, progress is restored. +3. Queue a whole season, stop AnimeBox mid-download, start it again. Expected: interrupted jobs are back in the queue + and continue; no orphaned partial files. +4. Cancel a running download. Expected: ffmpeg exits, partial file removed, no empty title folder. +5. AnimeVost (direct MP4): pause and resume a download. Expected: resumes from the same byte offset. +6. Delete the downloaded file manually, open the Library. Expected: the episode is hidden; **Rescan** removes the stale + record. + +### 4. Upgrade from 1.x + +Copy a 1.x `data/` folder (and `library/`) into the test folders and start 2.0. Expected: `data/backups/` contains a +copy; the Library lists only titles with existing files; history and progress are kept; titles that were only +browsed in 1.x are gone from the Library but still searchable. + +### 5. Network + +Configure a proxy in **Settings → Network**, repeat steps 2.3 and 3.1. Expected: playback and downloads work through +the proxy (signed CVH/OKCDN links require the same IP for resolution and fetching). 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 @@ <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <meta name="theme-color" content="#08070d" /> - <meta name="description" content="AnimeBox — локальная библиотека аниме для просмотра онлайн и офлайн." /> - <title>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..035bf0c 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,20 @@ }, "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" + }, + "engines": { + "node": ">=24" } } 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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - 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/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/frontend/src/assets/hero.png and /dev/null differ 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 @@ - \ 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 @@ -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/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/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/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/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/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