diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..34165ca Binary files /dev/null and b/.coverage differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..70321ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.idea +.mypy_cache +.pytest_cache +.ruff_cache +.venv +build +capture +captures +tests +*.egg-info +.coverage diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..667f489 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,58 @@ +name: Publish Docker Image + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - "v*" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + name: Build and publish Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.0.0 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6.1.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v7.2.0 + with: + context: . + file: docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..815ddf6 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,32 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + publish: + name: Build and publish package + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.14" + enable-cache: true + cache-suffix: publish-3.14 + + - name: Build distribution artifacts + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml deleted file mode 100644 index ac41a33..0000000 --- a/.github/workflows/publish-to-pypi.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Publish Python 🐍 distributions 📦 to PyPI -on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" - -jobs: - build-n-publish: - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@master - - - name: Build Changelog - id: github_release - uses: mikepenz/release-changelog-builder-action@v4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - failOnError: "true" - commitMode: "true" - - - name: Create Release - uses: actions/create-release@v1 - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: ${{steps.github_release.outputs.changelog}} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - - name: Install wheel - run: python -m pip install wheel - - - name: Build a source tarball - run: python setup.py sdist bdist_wheel - - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/readme-examples.yml b/.github/workflows/readme-examples.yml new file mode 100644 index 0000000..1551822 --- /dev/null +++ b/.github/workflows/readme-examples.yml @@ -0,0 +1,28 @@ +name: Verify README Examples + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +jobs: + verify-readme-examples: + name: Verify README example targets + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.14" + enable-cache: true + cache-suffix: readme-examples-3.14 + + - name: Sync dependencies + run: uv sync --locked + + - name: Verify README examples + run: uv run python scripts/verify_readme_examples.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f47d52e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,138 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + ty: + name: Ty type check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: ty-${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --locked + + - name: Run ty + run: uv run ty check mloader scripts tests + + ruff: + name: Ruff lint and format + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: ruff-${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --locked + + - name: Run ruff check + run: uv run ruff check . + + - name: Run ruff format + run: uv run ruff format --check . + + pytest: + name: Run pytest with coverage + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: pytest-${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --locked + + - name: Run tests + run: uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 + + capture-verify: + name: Verify capture schema fixtures + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: capture-verify-${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --locked + + - name: Verify baseline capture schema + run: | + uv run mloader --verify-capture-schema tests/fixtures/api_captures/baseline \ + --verify-capture-baseline tests/fixtures/api_captures/baseline + + docs-lint: + name: Docs lint (README option sync) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: docs-lint-${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --locked + + - name: Run docs lint tests + run: | + uv run python scripts/sync_readme_cli_reference.py --check + uv run pytest -q tests/test_readme_cli_options.py tests/test_cli_readme_reference.py diff --git a/.gitignore b/.gitignore index e539d90..f1a1d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,15 @@ log.txt ###################### extra/ venv/ +.venv/ # [date]full_name(id) report folders \[*\]*\(*\)/ *.egg-info -mloader_downloads/ \ No newline at end of file +mloader_downloads/ +build +capture/ +captures/ +.uv-cache/ +.ruff_cache/ +!tests/fixtures/**/*.json +mloader/.env \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1560fcb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +## Repository Map + +| Path | Topic / Responsibility | Inspect When | Ignore Unless | +| --- | --- | --- | --- | +| `mloader/cli/` | Click CLI surface, option handling, presenter output, examples | CLI behavior or public command flags/docs changes | Runtime engine logic only | +| `mloader/application/` | Orchestrates application use-cases and command-to-runtime bridges | Request construction, exporter selection, discovery/download flow | Parser/exporter internals | +| `mloader/domain/` | Domain models, planning contracts, request/data-shape definitions | Selector semantics, DTO shape, validation rules | CLI-only or pure transport work | +| `mloader/infrastructure/mangaplus/` | API gateway/auth, parsing/mapping, discovery + capture validation | API drift, parser/mapping changes, capture/sanity checks | Export/naming-only changes | +| `mloader/manga_loader/` | Runtime orchestration, manifest handling, execution pipeline | Download/resume behavior, title/chapter orchestration | Pure CLI formatting changes | +| `mloader/exporters/` | Raw/CBZ/PDF output adapters and naming conventions | Output behavior, archive/pdf composition, filename safety | Discovery/parser-only tasks | +| `mloader/types.py` | Internal protocol/type aliases and shared type signatures | Runtime wiring, test fakes signature alignment | Most feature edits | +| `tests/` | Behavioral/regression suites and shared fakes | Any functional change; start nearby tests first | Broad full-suite reads | +| `scripts/` | README sync, example verification helpers | Public CLI docs regeneration and example audits | Runtime-only fixes | +| `docker/` and `compose.yaml` | Container packaging, cron entrypoint, runtime args | Deployment/config changes | Download logic changes only | +| `docs/`, `README.md`, `AGENTS.md` | Documentation and contributor guidance | Public behavior/API docs or instructions | Internal algorithm refactors | + +## Common Task Routing + +| Task Type | Start Here | Then Check | +| --- | --- | --- | +| CLI/frontend changes | `mloader/cli/main.py`, `mloader/cli/command_requests.py`, `mloader/cli/readme_reference.py` | `tests/test_cli_main.py`, `tests/test_cli_command_requests.py`, `tests/test_readme_cli_options.py`, `scripts/sync_readme_cli_reference.py` | +| API/backend transport changes | `mloader/infrastructure/mangaplus/` (gateway/parsing/mappers/title discovery) | `tests/test_mangaplus_*`, `tests/test_capture_replay.py`, capture fixtures helpers | +| Domain/model changes | `mloader/domain/` + `mloader/manga_loader/chapter_planning.py` | `tests/test_domain_*`, `tests/test_runtime_chapter_planning.py` | +| Download/runtime changes | `mloader/manga_loader/`, `mloader/application/downloads.py`, `mloader/application/runner.py` (if present) | `tests/test_download_execution.py`, `tests/test_runtime_*`, `tests/test_downloader_title_assets.py`, `tests/test_filename_policy.py` | +| Exporter/output formatting | `mloader/exporters/`, `mloader/manga_loader/filename_policy.py` | `tests/test_exporters.py`, `tests/test_exporter_base.py` | +| Capture/schema changes | `mloader/infrastructure/mangaplus/capture*.py`, `mloader/infrastructure/mangaplus/parsing.py` | `tests/test_capture*`, replay fixture consumers | +| Build/deploy/config changes | `pyproject.toml`, `docker/`, `compose.yaml`, `.github/workflows/` | `tests/test_config_module.py`, config checks in CI scripts | +| Docs-only changes | `README.md`, `docs/`, `AGENTS.md` | `scripts/sync_readme_cli_reference.py --check`, example verifications | + +## Context Budget Rules + +- Start with the smallest relevant folder and one nearby test file. +- Do not read unrelated modules adjacent to your target area unless required by call graph. +- Avoid generated/vendor/build/cache directories by default. +- Read tests nearest to changed code before expanding scope. +- Avoid replay fixture payloads unless parser/capture/schema work requires them. +- Summarize findings before opening additional distant files. +- Keep edits minimal; prefer one-file-at-a-time changes for clarity. + +## Important Entry Points + +- `pyproject.toml`: package metadata, dependencies, test/type/lint config. +- `mloader/__main__.py`: command entrypoint wiring. +- `mloader/cli/main.py`: top-level click interface and option defaults. +- `mloader/application/requests.py`: request building + defaulting. +- `mloader/application/downloads.py`: runtime/application handoff and downloader factory wiring. +- `mloader/manga_loader/init.py`: public facade for programmatic usage. +- `mloader/manga_loader/runner.py` and `mloader/manga_loader/download_execution.py`: runtime orchestration. +- `mloader/infrastructure/mangaplus/`: API transport/parsing/discovery. +- `mloader/exporters/`: output implementations and naming behavior. +- `tests/cli_fakes.py`, `tests/http_fakes.py`: canonical behavior doubles. +- `scripts/sync_readme_cli_reference.py`: documentation synchronization for CLI tables. +- `docker/Dockerfile`, `docker/start-cron.sh`, `compose.yaml`: deployment execution path. + +## Do Not Touch / Ignore By Default + +- `mloader/response_pb2.py` / `.pyi`: generated protobuf artifacts; regenerate only for schema work. +- `tests/fixtures/api_captures/**/*`, capture baselines, and other replay payload binaries/json. +- `mloader/.env`, local auth/cache files, and output directories (`mloader_downloads/`, `capture/`, `captures/`) unless task demands. +- Tool/build artifacts: `.venv/`, `.pytest_cache/`, `.ruff_cache/`, `.uv-cache/`, `dist/`, `build/`, `*.egg-info/`. +- IDE/local state: `.idea/`, `app_textures/`, `code_cache/`. +- `uv.lock`: read/update only for dependency/version tasks. + +## Agent Workflow + +1. Classify the request type. +2. Inspect only the smallest relevant module set first. +3. Read nearby tests and test doubles before behavioral edits. +4. Make minimal, targeted changes. +5. Run targeted checks; include `uv run ty check mloader scripts tests` for typed contract changes. +6. Expand context only if required dependencies are missing. + +## Validation Checklist + +- All required headings exist in this order. +- Repo map and routing stay repository-specific (not generic). +- Ignore-by-default paths are explicit and practical. +- Recommendations are concise (table + short bullets). +- Open follow-ups list any uncertain areas for human confirmation. + +## Full quality gate (run before push / before opening PR) + +Run this complete sequence to mirror CI parity: + +1. `uv run ty check mloader scripts tests` +2. `uv run ruff check .` +3. `uv run ruff format --check .` +4. `uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100` +5. `uv run mloader --verify-capture-schema tests/fixtures/api_captures/baseline --verify-capture-baseline tests/fixtures/api_captures/baseline` +6. `uv run python scripts/sync_readme_cli_reference.py --check` +7. `uv run pytest -q tests/test_readme_cli_options.py tests/test_cli_readme_reference.py` +8. `uv run python scripts/verify_readme_examples.py` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fa24c59 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing + +Thanks for contributing to `mloader`. + +## Local setup + +1. Use Python 3.14 or newer. +2. Install package and development dependencies with `uv`: + +```bash +uv sync +``` + +## Development workflow + +1. Create a feature branch. +2. Implement change with tests. +3. Run the local quality gate: + +```bash +uv run ruff format --check . +uv run ruff check . +uv run ty check mloader scripts +uv run python scripts/sync_readme_cli_reference.py --check +uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 +``` + +4. Open a pull request with: +- problem statement +- implementation notes +- test evidence + +## Architecture overview + +Core areas: +- `mloader/cli/`: Click option declarations, request construction, presentation, and CLI error mapping. +- `mloader/application/`: download and discovery use cases. +- `mloader/domain/`: immutable request models, MangaPlus DTOs, and download planning. +- `mloader/infrastructure/mangaplus/`: MangaPlus transport, auth, parsing, DTO mapping, discovery, and capture verification. +- `mloader/manga_loader/`: `MangaLoader`, concrete runtime orchestration, manifests, decryption, filename policy, and download services. +- `mloader/exporters/`: output backends (`raw`, `cbz`, `pdf`) built on `ExporterBase`. + +## Extension points + +### Add a new exporter + +1. Create a new file in `mloader/exporters/`. +2. Subclass `ExporterBase`. +3. Define a unique `format` class attribute. +4. Implement: +- `add_image(self, image_data, index)` +- `skip_image(self, index)` +5. Export your class from `mloader/exporters/__init__.py`. +6. Add tests in `tests/test_exporters.py` (or a new test module). +7. If the exporter becomes user-facing, update CLI choices, request models, exporter resolution, README reference tests, and docs. + +### Add CLI options + +1. Add a new click option in `mloader/cli/main.py`. +2. Thread option data through `mloader/cli/command_requests.py` and `mloader/application/requests.py`. +3. Keep command behavior in the focused `mloader/cli/*_command.py` module that owns it. +4. Add CLI tests for parsing, help/reference output when relevant, JSON output, run reports, and failure behavior. + +### Add MangaPlus/runtime behavior + +1. Keep MangaPlus HTTP, auth, payload classification, protobuf parsing, and DTO mapping under `mloader/infrastructure/mangaplus/`. +2. Keep pure selection and planning logic in `mloader/domain/`. +3. Keep download orchestration in `mloader/manga_loader/` services. +4. Add or update capture fixtures when upstream API shapes change. +5. Cover behavior in tests with fakes, fixtures, or capture replay; unit tests should not require network access. + +## Quality bar + +- No untested behavior changes. +- No network in unit tests. +- Prefer small, composable changes over broad rewrites. +- Do not add re-export modules for removed internal paths. +- Keep the documented CLI, Docker behavior, output files, manifests, JSON output, and exit codes stable unless the change is an explicit product decision. diff --git a/README.md b/README.md index 4c77ce7..ffe2f3c 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,263 @@ # Mangaplus Downloader -[![Latest Github release](https://img.shields.io/github/tag/hurlenko/mloader.svg)](https://github.com/hurlenko/mloader/releases/latest) -![Python](https://img.shields.io/badge/python-v3.6+-blue.svg) +![Version](https://img.shields.io/badge/version-v2.1.2-brightgreen.svg) +![Python](https://img.shields.io/badge/python-v3.14+-blue.svg) +![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg) ![License](https://img.shields.io/badge/license-GPLv3-blue.svg) ## **mloader** - download manga from mangaplus.shueisha.co.jp ## 🚩 Table of Contents -- [Installation](#-installation) -- [Usage](#-usage) -- [Command line interface](#%EF%B8%8F-command-line-interface) +- [Installation](#-installation) +- [Development](#-development) +- [Testing](#-testing) +- [Docker](#-docker) +- [Usage](#-usage) +- [Command line interface](#%EF%B8%8F-command-line-interface) +- [Extending mloader](#-extending-mloader) ## 💾 Installation -The recommended installation method is using `pip`: +The recommended installation method is using `uv`: ```bash -pip install mloader +uv tool install mloader-ng ``` -After installation, the `mloader` command will be available. Check the [command line](%EF%B8%8F-command-line-interface) section for supported commands. +After installation, the `mloader` command will be available. Check the [command line](#%EF%B8%8F-command-line-interface) section for supported commands. + +If you prefer `pip`, `pip install mloader-ng` still works. + +## 🛠 Development + +```bash +git clone https://github.com/l0westbob/mloader.git +cd mloader +uv sync +``` + +This package is published as `mloader-ng` (temporary maintained rewrite fork). +The CLI command remains `mloader`. + +### Stability and API access posture + +`mloader` is maintained as a stable production CLI/Docker app. Current development is evolutionary: +small, tested hardening changes are preferred over large rewrites so current cron jobs, paths, flags, +manifest files, and exit codes stay stable. + +The auth settings shipped in this repository are suitable for free-tier/local development only. They +can download free-access chapters, but they must not be treated as proof that subscription/MAX or +full-catalog downloads work. Full-catalog cron usage requires your own subscription-capable auth +settings via environment variables, config, or Docker secrets. + +When the free-tier key hits a subscription-only chapter, `mloader` should fail cleanly with an +external/access error such as “subscription required”, not an internal bug. + +## ✅ Testing + +```bash +uv run pytest +``` + +Coverage is enforced at **100%** in CI: + +```bash +uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 +``` + +Lint and format checks run through Ruff: + +```bash +uv run ruff check . +uv run ruff format --check . +``` + +Type checking runs through `ty`. The project does not maintain a Pyright configuration; `ty` is the +single supported local and CI type checker. + +```bash +uv run ty check mloader scripts tests +``` + +Run the same local quality gate used for implementation phases with: + +```bash +uv run ruff format --check . +uv run ruff check . +uv run ty check mloader scripts tests +uv run python scripts/sync_readme_cli_reference.py --check +uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 +``` + +Verify README example targets against live MangaPlus API responses: + +```bash +uv run python scripts/verify_readme_examples.py +``` + +Safe local smoke checks that do not require paid access: + +```bash +uv run pytest -q +uv run mloader --all --list-only --language english +uv run mloader --chapter-id 1024959 --out /tmp/mloader-smoke +``` + +Subscription-limited/full-catalog checks should be run only with your own subscription-capable auth +settings. With the repository default/free-tier auth, subscription-only chapters are expected to fail +as controlled external access failures. + +## 🐳 Docker + +`docker/Dockerfile` installs `mloader` from the local repository files. + +Release images are published to GitHub Container Registry at +[`ghcr.io/l0westbob/mloader`](https://github.com/l0westbob/mloader/pkgs/container/mloader). +The `latest` tag points at the newest image published from `main`. + +```bash +docker pull ghcr.io/l0westbob/mloader:latest +docker pull ghcr.io/l0westbob/mloader:2.1.2 +``` + +The image workflow publishes on `main`, `v*` tags, and manual dispatch. If GHCR creates +the package as private after the first push, make it public once in the package settings. + +To use the published image in Compose, replace the local `build:` block with: + +```yaml +image: ghcr.io/l0westbob/mloader:2.1.2 +``` + +The default `compose.yaml` now runs a long-lived cron daemon inside the container and executes `mloader` weekly. +The container preserves explicit `--out /downloads` behavior, uses a lock directory to avoid +overlapping cron runs, and logs clear start/end markers with exit codes. + +Default schedule and arguments: + +```bash +MLOADER_CRON_SCHEDULE="0 3 * * 1" +MLOADER_CRON_ARGS="--all --language english --format pdf --out /downloads --cover" +``` + +This means: every Monday at 03:00 container time. + +For full-catalog Docker runs, replace the free-tier repository auth settings with your own +subscription-capable settings. Otherwise subscription-only chapters will be reported as controlled +external access failures. + +Useful runtime knobs in `compose.yaml`: + +- `MLOADER_CRON_SCHEDULE`: standard 5-field cron expression. +- `MLOADER_CRON_ARGS`: arguments passed to `mloader` for scheduled runs. +- `MLOADER_RUN_ON_START`: `"true"` to run one job immediately on container startup. +- `MLOADER_RUN_REPORT_PATH`: optional JSON report path for weekly unattended runs. +- `MLOADER_CRON_LOCK_DIR`: lock directory used to skip overlapping schedule ticks. + +Run in background: + +```bash +docker compose up -d --build +``` + +Check scheduler logs: + +```bash +docker compose logs -f mloader +``` ## 📙 Usage Copy the url of the chapter or title you want to download and pass it to `mloader`. -You can use `--title` and `--chapter` command line argument to download by title and chapter id. +Use `--title` with `--chapter` to target chapter numbers, or `--chapter-id` for direct API chapter IDs. You can download individual chapters or full title (but only available chapters). -Chapters can be saved as `CBZ` archives (default) or separate images by passing the `--raw` parameter. +Chapters can be saved in different formats (check the `--help` output for the available formats). + +By default, `mloader` writes each chapter as a CBZ archive. CBZ archives contain page images plus +a root-level `ComicInfo.xml` metadata file for comic library apps such as Komga. When MangaPlus +provides the data, `ComicInfo.xml` includes: + +- series and chapter title, chapter number, language, publisher, manga reading direction, and + digital format +- author in the ComicInfo `Writer` field +- title summary, MangaPlus tags as both `Genre` and `Tags`, and the MangaPlus web/share URL +- chapter release date (`Year`, `Month`, `Day`) and page count + +If MangaPlus does not provide tags, CBZ metadata falls back to `Genre` = `Manga`. + +Typical MangaPlus IDs are multi-digit integers (title IDs are commonly 6 digits), for example: + +```bash +mloader https://mangaplus.shueisha.co.jp/viewer/1024959 +mloader https://mangaplus.shueisha.co.jp/titles/100312 -f pdf +mloader --title 100312 --chapter 12 +mloader --chapter-id 1024959 +mloader --title 100312 --cover +mloader --title 100312 --cover-format webp +``` + +For an exhaustive, option-complete command catalog (including discovery, capture, resume, and output modes): + +```bash +mloader --show-examples +``` + +When `--capture-api` is enabled, mloader stores every fetched API payload (raw protobuf + metadata + parsed JSON when possible). This is useful for regression fixture collection and for tracking upstream API changes over time. + +Every title directory now includes a resumable download manifest at `.mloader-manifest.json`. +Rerunning the same command skips chapters already marked as completed and retries chapters that previously failed or were interrupted. + +Use `--no-resume` to ignore manifest state for a run, or `--manifest-reset` to clear manifest state before downloading. + +Download all discoverable titles from MangaPlus list pages with one command. This is a +subscription-auth example when paired with non-free chapters: + +```bash +mloader --all --format pdf +``` + +As of February 25, 2026, this will download 24,944 chapters over a total of 637 titles with a size of around 220GB (English catalog). + +The bulk command uses protobuf API discovery first (`/api/title_list/allV2`), then falls back to +static page scraping and optional browser-rendered scraping (`--browser-fallback`, enabled by +default) when needed. + +For unattended Docker/cron runs, `--run-report ` or `MLOADER_RUN_REPORT_PATH` writes a JSON +report with run timing, selected args, discovered-title count, summary counters, access-failure +count, and exporter safety mode. + +Restrict bulk discovery to specific languages: + +```bash +mloader --all --language english --language spanish --list-only +``` + +Supported `--language` values: +- `english` +- `spanish` +- `french` +- `indonesian` +- `portuguese` +- `russian` +- `thai` +- `german` +- `vietnamese` + +As of February 24, 2026, all the languages above are present in the live `allV2` payload. + +Install browser runtime locally with: + +```bash +playwright install chromium +``` ## 🖥️ Command line interface -Currently `mloader` supports these commands +Currently `mloader` supports these options ``` Usage: mloader [OPTIONS] [URLS]... @@ -43,22 +266,172 @@ Usage: mloader [OPTIONS] [URLS]... Options: --version Show the version and exit. - -o, --out Save directory (not a file) [default: + --json Emit structured JSON output to stdout + --quiet Suppress non-error human-readable output + --show-examples Print exhaustive command examples and exit + --verbose Increase logging verbosity (repeatable) + -o, --out Output directory for downloads [default: mloader_downloads] - -r, --raw Save raw images [default: False] + --verify-capture-schema + Verify captured API payloads + against required response schema fields and + exit + --verify-capture-baseline + Compare verified capture schema + signatures against a baseline capture + directory + --all Discover all available titles and + download them + --page TEXT MangaPlus list page to scrape for title + links (repeatable) + --title-index-endpoint TEXT MangaPlus mobile API endpoint used for + API-first title discovery + --id-length INTEGER RANGE If set, keep only title IDs with this + exact digit length + --language [english|spanish|french|indonesian|portuguese|russian|thai|german|vietnamese] + Restrict --all discovery to one or + more languages (repeatable) + --list-only Only print discovered title IDs for + --all and exit + --browser-fallback / --no-browser-fallback + Use Playwright-rendered scraping when + static page fetch yields no title IDs + -r, --raw Save raw images + -f, --format [cbz|pdf] Save as CBZ or PDF [default: cbz] + --capture-api Dump raw API payload captures (protobuf + + metadata) to this directory + --run-report Write a JSON run report for unattended + cron/systemd runs -q, --quality [super_high|high|low] Image quality [default: super_high] - -s, --split Split combined images [default: False] - -c, --chapter INTEGER Chapter id + -s, --split Split combined images + -c, --chapter INTEGER Chapter number + --chapter-id INTEGER Chapter API ID -t, --title INTEGER Title id -b, --begin INTEGER RANGE Minimal chapter to try to download [default: 0;x>=0] -e, --end INTEGER RANGE Maximal chapter to try to download [x>=1] -l, --last Download only the last chapter for title - [default: False] --chapter-title Include chapter titles in filenames - [default: False] - --chapter-subdir Save raw images in sub directory by chapter - [default: False] + --chapter-subdir Save raw images in subdirectories by chapter + -m, --meta Export additional metadata as JSON + --cover Download each title cover image (PNG by + default) + --cover-format [png|jpg|webp] Cover image format; implies --cover when + provided [default: png] + --resume / --no-resume Use per-title manifest state to skip + already completed chapters + --manifest-reset Reset per-title manifest state before + downloading --help Show this message and exit. -``` \ No newline at end of file +``` + +Output mode behavior: + +- `--json`: emits machine-readable JSON payloads for successful command completion and controlled command failures. +- `--quiet`: suppresses intro and informational command output. +- `--verbose`: enables debug-level logging. +- `--format cbz`: writes a root-level `ComicInfo.xml` with available MangaPlus metadata. + +Download run summaries include: +- downloaded chapter count +- manifest-skipped chapter count +- failed chapter count and failed chapter IDs + +### Parameter reference + +This section is generated from CLI metadata. Update it with `uv run python scripts/sync_readme_cli_reference.py`. + + +`URLS`: +- Positional MangaPlus URLs (`viewer/` and `titles/`). + +| Option | Description | Default | Env | +| --- | --- | --- | --- | +| `--version` | Show the version and exit. | `false` | `-` | +| `--json` | Emit structured JSON output to stdout | `false` | `-` | +| `--quiet` | Suppress non-error human-readable output | `false` | `-` | +| `--show-examples` | Print exhaustive command examples and exit | `false` | `-` | +| `--verbose`, `-v` | Increase logging verbosity (repeatable) | `0` | `-` | +| `--out`, `-o` | Output directory for downloads | `mloader_downloads` | `MLOADER_EXTRACT_OUT_DIR` | +| `--verify-capture-schema` | Verify captured API payloads against required response schema fields and exit | `-` | `-` | +| `--verify-capture-baseline` | Compare verified capture schema signatures against a baseline capture directory | `-` | `-` | +| `--all` | Discover all available titles and download them | `false` | `-` | +| `--page` | MangaPlus list page to scrape for title links (repeatable) | `https://mangaplus.shueisha.co.jp/manga_list/ongoing, https://mangaplus.shueisha.co.jp/manga_list/completed, https://mangaplus.shueisha.co.jp/manga_list/one_shot` | `-` | +| `--title-index-endpoint` | MangaPlus mobile API endpoint used for API-first title discovery | `https://jumpg-api.tokyo-cdn.com/api/title_list/allV2` | `MLOADER_TITLE_INDEX_ENDPOINT` | +| `--id-length` | If set, keep only title IDs with this exact digit length | `-` | `-` | +| `--language` | Restrict --all discovery to one or more languages (repeatable) | `-` | `-` | +| `--list-only` | Only print discovered title IDs for --all and exit | `false` | `-` | +| `--browser-fallback`, `--no-browser-fallback` | Use Playwright-rendered scraping when static page fetch yields no title IDs | `true` | `-` | +| `--raw`, `-r` | Save raw images | `false` | `MLOADER_RAW` | +| `--format`, `-f` | Save as CBZ or PDF | `cbz` | `MLOADER_OUTPUT_FORMAT` | +| `--filename-style` | Filename style for chapter-level outputs (legacy excludes language tags) | `legacy` | `MLOADER_FILENAME_STYLE` | +| `--rename-existing-filenames` | Rename existing legacy chapter filenames to the selected filename style | `false` | `-` | +| `--capture-api` | Dump raw API payload captures (protobuf + metadata) to this directory | `-` | `MLOADER_CAPTURE_API_DIR` | +| `--run-report` | Write a JSON run report for unattended cron/systemd runs | `-` | `MLOADER_RUN_REPORT_PATH` | +| `--quality`, `-q` | Image quality | `super_high` | `MLOADER_QUALITY` | +| `--split`, `-s` | Split combined images | `false` | `MLOADER_SPLIT` | +| `--chapter`, `-c` | Chapter number (integer, e.g. 1, 12) | `-` | `-` | +| `--chapter-id` | Chapter API ID (integer, e.g. 1024959) | `-` | `-` | +| `--title`, `-t` | Title ID (integer, usually 6 digits, e.g. 100312) | `-` | `-` | +| `--begin`, `-b` | Minimal chapter to download | `0` | `-` | +| `--end`, `-e` | Maximal chapter to download | `-` | `-` | +| `--last`, `-l` | Download only the last chapter for each title | `false` | `-` | +| `--chapter-title` | Include chapter titles in filenames | `false` | `-` | +| `--chapter-subdir` | Save raw images in subdirectories by chapter | `false` | `-` | +| `--meta`, `-m` | Export additional metadata as JSON | `false` | `-` | +| `--cover` | Download each title cover image (PNG by default) | `false` | `-` | +| `--cover-format` | Cover image format; implies --cover when provided | `png` | `-` | +| `--resume`, `--no-resume` | Use per-title manifest state to skip already completed chapters | `true` | `-` | +| `--manifest-reset` | Reset per-title manifest state before downloading | `false` | `-` | + + +Deterministic exit-code mapping: + +- `0`: success +- `2`: user input/usage error (Click argument parsing) +- `3`: validation error (invalid CLI option combinations, schema verification validation) +- `4`: external failure (upstream API/subscription/access failures) +- `5`: internal bug/unexpected runtime failure + +Runtime auth settings (`app_ver`, `os`, `os_ver`, `secret`) are resolved with this priority: + +1. CLI/runtime overrides (internal, reserved for programmatic usage) +2. Environment variables: `APP_VER`, `OS`, `OS_VER`, `SECRET` +3. Config file: `MLOADER_CONFIG_FILE` (or local `.mloader.toml`) +4. Built-in defaults + +Example TOML config: + +```toml +[auth] +app_ver = "97" +os = "ios" +os_ver = "18.1" +secret = "your-secret" +``` + +When `--meta` is enabled, `title_metadata.json` stores chapters keyed by chapter ID (`"chapters": {"": ...}`) and includes each chapter `sub_title` and `thumbnail_url`. + +Verify your recorded payload set: + +```bash +mloader --verify-capture-schema ./capture +``` + +Compare a new capture run against your committed baseline: + +```bash +mloader --verify-capture-schema ./capture --verify-capture-baseline ./tests/fixtures/api_captures/baseline +``` + +## 🧩 Extending mloader + +`mloader` is designed around domain DTOs, composed runtime services, and exporter classes. + +- Add a new exporter by subclassing `ExporterBase`. +- Set `format = ""` in your exporter. +- Implement `add_image` and `skip_image`. + +See `CONTRIBUTING.md` for architecture and extension details. +Detailed architecture notes are in `docs/ARCHITECTURE.md`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9333206 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +services: + mloader: + container_name: "mloader" + build: + context: . + dockerfile: docker/Dockerfile + environment: + APP_VER: 237 + OS: android + OS_VER: 35 + SECRET: b66ce3fdbc129d49e31da0738ff5943b + MLOADER_EXTRACT_OUT_DIR: /downloads + MLOADER_CRON_SCHEDULE: "0 3 * * 1" + MLOADER_CRON_ARGS: "--all --language english --format pdf --out /downloads --cover" + MLOADER_RUN_ON_START: "true" + volumes: + - /mnt/data/MangaPlus:/downloads + entrypoint: ["/usr/local/bin/start-cron.sh"] + restart: unless-stopped diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7fa27e6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.14-slim +COPY --from=ghcr.io/astral-sh/uv:0.10.12 /uv /uvx /bin/ + +WORKDIR /app + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends cron \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock README.md response.proto .python-version /app/ +COPY mloader /app/mloader +RUN uv sync --locked --no-dev \ + && uv run playwright install --with-deps chromium + +COPY docker/start-cron.sh /usr/local/bin/start-cron.sh +RUN chmod +x /usr/local/bin/start-cron.sh + +ENV PATH="/app/.venv/bin:${PATH}" + +ENTRYPOINT ["mloader"] diff --git a/docker/start-cron.sh b/docker/start-cron.sh new file mode 100755 index 0000000..2161c39 --- /dev/null +++ b/docker/start-cron.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env sh +set -eu + +CRON_SCHEDULE="${MLOADER_CRON_SCHEDULE:-0 3 * * 1}" +CRON_ARGS="${MLOADER_CRON_ARGS:---all --language english --format pdf}" +RUN_ON_START="${MLOADER_RUN_ON_START:-false}" +OUT_DIR="${MLOADER_EXTRACT_OUT_DIR:-mloader_downloads}" +RUN_REPORT_PATH="${MLOADER_RUN_REPORT_PATH:-}" +LOCK_DIR="${MLOADER_CRON_LOCK_DIR:-/tmp/mloader-cron.lock}" +CRON_FILE="/etc/cron.d/mloader" +RUNNER_FILE="/usr/local/bin/run-mloader-cron.sh" +MLOADER_BIN="${MLOADER_BIN:-/app/.venv/bin/mloader}" +PATH="/app/.venv/bin:${PATH}" + +if [ ! -x "${MLOADER_BIN}" ]; then + if command -v mloader >/dev/null 2>&1; then + MLOADER_BIN="$(command -v mloader)" + else + echo "Error: mloader executable not found (checked ${MLOADER_BIN} and PATH)." >&2 + exit 127 + fi +fi + +case " ${CRON_ARGS} " in + *" --out "*|*" -o "*) + CRON_ARGS_WITH_OUT="${CRON_ARGS}" + ;; + *) + CRON_ARGS_WITH_OUT="--out ${OUT_DIR} ${CRON_ARGS}" + ;; +esac + +case " ${CRON_ARGS_WITH_OUT} " in + *" --run-report "*) + ;; + *) + if [ -n "${RUN_REPORT_PATH}" ]; then + CRON_ARGS_WITH_OUT="${CRON_ARGS_WITH_OUT} --run-report ${RUN_REPORT_PATH}" + fi + ;; +esac + +echo "Configuring weekly mloader cron job..." +echo "Schedule: ${CRON_SCHEDULE}" +echo "Out dir: ${OUT_DIR}" +echo "Args: ${CRON_ARGS_WITH_OUT}" +echo "Binary: ${MLOADER_BIN}" + +cat > "${RUNNER_FILE}" </dev/null; then + cleanup() { + rmdir "\${LOCK_DIR}" 2>/dev/null || true + } + trap cleanup EXIT INT TERM + run_started=\$(date -u +"%Y-%m-%dT%H:%M:%SZ") + echo "mloader cron run started at \${run_started}: \${CMD}" + set +e + /bin/sh -lc "\${CMD}" + exit_code=\$? + set -e + run_finished=\$(date -u +"%Y-%m-%dT%H:%M:%SZ") + echo "mloader cron run finished at \${run_finished} with exit code \${exit_code}" + exit "\${exit_code}" +else + echo "Another mloader cron run is still active; skipping this schedule tick." +fi +EOF + +chmod +x "${RUNNER_FILE}" + +cat > "${CRON_FILE}" <> /proc/1/fd/1 2>> /proc/1/fd/2 +EOF + +chmod 0644 "${CRON_FILE}" + +if [ "${RUN_ON_START}" = "true" ]; then + echo "Running initial mloader execution before cron starts..." + initial_started=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + echo "mloader initial run started at ${initial_started}: ${MLOADER_BIN} ${CRON_ARGS_WITH_OUT}" + set +e + /bin/sh -lc "${MLOADER_BIN} ${CRON_ARGS_WITH_OUT}" + initial_exit_code=$? + set -e + initial_finished=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + echo "mloader initial run finished at ${initial_finished} with exit code ${initial_exit_code}" + if [ "${initial_exit_code}" -ne 0 ]; then + exit "${initial_exit_code}" + fi +fi + +echo "Starting cron daemon in foreground..." +exec cron -f diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1d7a295 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# Architecture + +`mloader` is a stable Python CLI and Docker-friendly downloader for MangaPlus. The public contract is +the `mloader` command, Docker/cron behavior, output paths, filenames, manifests, exit codes, capture +fixtures, JSON output, `python -m mloader`, `mloader.manga_loader.init.MangaLoader`, and exporter +package imports. Internal Python import paths are not supported API; code should use the +canonical layer that owns the behavior. + +## Current status + +The runtime is now DTO-first. MangaPlus protobuf payloads are parsed inside +`mloader/infrastructure/mangaplus`, mapped immediately into immutable domain objects, and consumed by +download planning and export orchestration as stable DTOs. Generated protobuf classes should not cross +the infrastructure boundary except in capture verification and capture replay tests. + +The repository/default auth settings are free-tier development credentials. They are useful for +free-access chapters, fixtures, smoke checks, and controlled subscription/access failure paths, but +they cannot validate full-catalog subscription downloads. Full-catalog cron usage depends on +user-provided subscription-capable auth settings. + +## Runtime flow + +1. `mloader/cli/main.py` owns Click option declarations and terminal entry behavior. +2. `mloader/cli/command_requests.py` builds immutable CLI request models from parsed options. +3. `mloader/application/requests.py`, `discovery.py`, and `downloads.py` own request construction, + title discovery orchestration, exporter selection, and download execution. +4. `mloader/infrastructure/mangaplus/title_discovery.py` is the discovery gateway composition + point; title-index API parsing lives in `title_index.py`, static list-page scraping in + `static_discovery.py`, and Playwright fallback scraping in `browser_discovery.py`. +5. `mloader/infrastructure/mangaplus/gateway.py` owns MangaPlus HTTP sessions, mobile headers, auth + params, retries, payload capture, parser calls, DTO mapping, and run-scoped caches. +6. `mloader/domain/planning.py` turns validated title/chapter filters into a `DownloadPlan`. +7. `MangaLoader` is the public programmatic facade and instantiates the concrete `DownloadRunner` + directly. +8. `DownloadRunner` is a composition root: it wires the MangaPlus gateway and delegates concrete + execution to `mloader/manga_loader/download_execution.py`. +9. Download execution is split across title, chapter, page-image, page-export, metadata, cover, + manifest, filename, and planning services. +10. Exporters write raw images, CBZ, or PDF files from domain-shaped title/chapter objects. + +## Layering + +- `mloader/cli`: Click parsing, CLI request construction, presentation, and command behavior. +- `mloader/application`: command/use-case orchestration that should stay independent of protobuf. +- `mloader/domain`: immutable request models, MangaPlus DTOs, planning models, and pure selection + logic. +- `mloader/infrastructure/mangaplus`: MangaPlus endpoints, auth params, payload classification, + protobuf parsing, DTO mappers, shared transport/capture helpers, capture verification, gateway + transport, title-index discovery, static discovery, and browser fallback adapters. +- `mloader/manga_loader`: public `MangaLoader` facade, concrete `DownloadRunner` composition root, + `DownloadExecutionService`, manifests, decryption helpers, filename policy, and composed download + runtime services. Runtime orchestration does not use a template-method coordinator base class. +- `mloader/exporters`: filesystem output adapters. +- `mloader/config.py`: immutable layered auth settings (overrides > env > file > defaults). + +## Exporter model + +All exporters inherit from `ExporterBase`: +- `add_image(image_data, index)` +- `skip_image(index)` +- `close()` +- `discard()` for failed runs that must clean temporary buffers without publishing artifacts + +Current implementations: +- `RawExporter` +- `CBZExporter` writes to a temporary archive in the target directory and atomically replaces the + final `.cbz` only after the ZIP closes successfully. +- `PDFExporter` keeps page data on disk and streams `img2pdf` output to a temporary PDF before + atomically replacing the final `.pdf`. + +Exporters depend on `mloader.types.TitleLike` and `ChapterLike` instead of generated protobuf +classes. Runtime callers pass domain DTOs through those protocols, keeping filename/output behavior +stable without leaking MangaPlus protobuf objects across the infrastructure boundary. + +New formats should follow the same contract to keep current `MangaLoader` behavior stable. + +Double-page naming note: +- DOUBLE spreads are represented with `range(start, stop)` indexes where `stop` is treated as an inclusive paired page marker for filename formatting (for example `p000-001`). + +## Testing strategy + +- Unit tests for deterministic logic (domain planning, validators, naming, filtering). +- Behavior tests for CLI orchestration. +- MangaPlus discovery tests are split by adapter: title index, static pages, browser fallback, and + gateway composition. +- Capture verification tests are split by filesystem/baseline behavior, direct payload validation, + and schema-signature helpers. +- Exporter tests with temporary directories and synthetic image bytes. +- Capture replay fixtures for title-detail, title-index, API-error, subscription-required, and + encrypted-page payload shapes. +- No network calls in unit tests. +- Type checking uses `ty` as the only supported type checker in local development and CI; the + project gate is `uv run ty check mloader scripts tests`, so tests are typed clients of the + runtime contracts too. + +## Error boundaries + +Runtime/domain errors in `mloader/errors.py` expose an `error_kind` such as +`external_dependency`, `subscription_required`, `interrupted`, or `internal_bug`. Application +errors mirror the same taxonomy. CLI command modules should map failures through +`mloader/cli/error_mapping.py` so exit codes, run-report status, and subscription failure counts stay +centralized instead of being reinterpreted in each command branch. + +## Current debt + +- `mloader/cli/main.py` still owns Click option declarations; command behavior lives in focused + `mloader/cli/*_command.py` and `mloader/cli/run_report.py` modules. +- Filesystem naming policy is centralized in `mloader/manga_loader/filename_policy.py`; preserve + existing output names with golden tests before changing it. +- Download planning carries title details loaded during selection so duplicate fetch prevention does + not depend on gateway caches. +- Capture persistence and verification live under infrastructure; keep new capture behavior there. +- Deleted internal paths are gone. Do not add re-export modules for removed internals; use the + canonical module that owns the behavior. + +## Layout decision + +Keep the flat package layout for now. A `src/` move would create packaging and Docker churn without +removing meaningful runtime coupling. + +Public CLI breaking changes should be treated as product decisions with release notes. The default +modernization path keeps the `mloader` command, Docker behavior, output paths, manifests, exit codes, +and capture fixtures stable. diff --git a/mloader/.env b/mloader/.env new file mode 100644 index 0000000..3af7908 --- /dev/null +++ b/mloader/.env @@ -0,0 +1,5 @@ +APP_VER=97 +OS=ios +OS_VER=18.1 +# will be invalid 1st march 2026 +SECRET=e75b23a6296a2575a49b5449119a72d8 \ No newline at end of file diff --git a/mloader/__init__.py b/mloader/__init__.py index e69de29..ad4bf8a 100644 --- a/mloader/__init__.py +++ b/mloader/__init__.py @@ -0,0 +1 @@ +"""Top-level package for mloader.""" diff --git a/mloader/__main__.py b/mloader/__main__.py index afcad18..4297685 100644 --- a/mloader/__main__.py +++ b/mloader/__main__.py @@ -1,231 +1,6 @@ -import logging -import re -import sys -from functools import partial -from typing import Optional, Set +"""Application entrypoint used by ``python -m mloader``.""" -import click +from mloader.cli.main import main -from mloader import __version__ as about -from mloader.exporter import RawExporter, CBZExporter -from mloader.loader import MangaLoader - -log = logging.getLogger() - - -def setup_logging(): - for logger in ("requests", "urllib3"): - logging.getLogger(logger).setLevel(logging.WARNING) - handlers = [logging.StreamHandler(sys.stdout)] - logging.basicConfig( - handlers=handlers, - format=( - "{asctime:^} | {levelname: ^8} | " - "{filename: ^14} {lineno: <4} | {message}" - ), - style="{", - datefmt="%d.%m.%Y %H:%M:%S", - level=logging.INFO, - ) - - -setup_logging() - - -def validate_urls(ctx: click.Context, param, value): - if not value: - return value - - res = {"viewer": set(), "titles": set()} - for url in value: - match = re.search(r"(\w+)/(\d+)", url) - if not match: - raise click.BadParameter(f"Invalid url: {url}") - try: - res[match.group(1)].add(int(match.group(2))) - except (ValueError, KeyError): - raise click.BadParameter(f"Invalid url: {url}") - - ctx.params.setdefault("titles", set()).update(res["titles"]) - ctx.params.setdefault("chapters", set()).update(res["viewer"]) - - -def validate_ids(ctx: click.Context, param, value): - if not value: - return value - - assert param.name in ("chapter", "title") - - ctx.params.setdefault(f"{param.name}s", set()).update(value) - - -EPILOG = f""" -Examples: - -{click.style('• download manga chapter 1 as CBZ archive', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/viewer/1 - -{click.style('• download all chapters for manga title 2 and save ' -'to current directory', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/titles/2 -o . - -{click.style('• download chapter 1 AND all available chapters from ' -'title 2 (can be two different manga) in low quality and save as ' -'separate images', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/viewer/1 - https://mangaplus.shueisha.co.jp/titles/2 -r -q low -""" - - -@click.command( - help=about.__description__, - epilog=EPILOG, -) -@click.version_option( - about.__version__, - prog_name=about.__title__, - message="%(prog)s by Hurlenko, version %(version)s\n" - f"Check {about.__url__} for more info", -) -@click.option( - "--out", - "-o", - "out_dir", - type=click.Path(exists=False, writable=True), - metavar="", - default="mloader_downloads", - show_default=True, - help="Save directory (not a file)", - envvar="MLOADER_EXTRACT_OUT_DIR", -) -@click.option( - "--raw", - "-r", - is_flag=True, - default=False, - show_default=True, - help="Save raw images", - envvar="MLOADER_RAW", -) -@click.option( - "--quality", - "-q", - default="super_high", - type=click.Choice(["super_high", "high", "low"]), - show_default=True, - help="Image quality", - envvar="MLOADER_QUALITY", -) -@click.option( - "--split", - "-s", - is_flag=True, - default=False, - show_default=True, - help="Split combined images", - envvar="MLOADER_SPLIT", -) -@click.option( - "--chapter", - "-c", - type=click.INT, - multiple=True, - help="Chapter id", - expose_value=False, - callback=validate_ids, -) -@click.option( - "--title", - "-t", - type=click.INT, - multiple=True, - help="Title id", - expose_value=False, - callback=validate_ids, -) -@click.option( - "--begin", - "-b", - type=click.IntRange(min=0), - default=0, - show_default=True, - help="Minimal chapter to try to download", -) -@click.option( - "--end", - "-e", - type=click.IntRange(min=1), - help="Maximal chapter to try to download", -) -@click.option( - "--last", - "-l", - is_flag=True, - default=False, - show_default=True, - help="Download only the last chapter for title", -) -@click.option( - "--chapter-title", - is_flag=True, - default=False, - show_default=True, - help="Include chapter titles in filenames", -) -@click.option( - "--chapter-subdir", - is_flag=True, - default=False, - show_default=True, - help="Save raw images in sub directory by chapter", -) -@click.argument("urls", nargs=-1, callback=validate_urls, expose_value=False) -@click.pass_context -def main( - ctx: click.Context, - out_dir: str, - raw: bool, - quality: str, - split: bool, - begin: int, - end: int, - last: bool, - chapter_title: bool, - chapter_subdir: bool, - chapters: Optional[Set[int]] = None, - titles: Optional[Set[int]] = None, -): - click.echo(click.style(about.__doc__, fg="blue")) - if not any((chapters, titles)): - click.echo(ctx.get_help()) - return - end = end or float("inf") - log.info("Started export") - - exporter = RawExporter if raw else CBZExporter - exporter = partial( - exporter, - destination=out_dir, - add_chapter_title=chapter_title, - add_chapter_subdir=chapter_subdir, - ) - - loader = MangaLoader(exporter, quality, split) - try: - loader.download( - title_ids=titles, - chapter_ids=chapters, - min_chapter=begin, - max_chapter=end, - last_chapter=last, - ) - except Exception: - log.exception("Failed to download manga") - log.info("SUCCESS") - - -if __name__ == "__main__": - main(prog_name=about.__title__) +if __name__ == "__main__": # pragma: no cover + main() diff --git a/mloader/__version__.py b/mloader/__version__.py index 981c900..fbc08ac 100644 --- a/mloader/__version__.py +++ b/mloader/__version__.py @@ -1,13 +1,16 @@ -""" +"""Project metadata constants.""" + +__intro__ = r""" _ _ _ __ ___ | | ___ __ _ __| | ___ _ __ | '_ ` _ \| |/ _ \ / _` |/ _` |/ _ \ '__| | | | | | | | (_) | (_| | (_| | __/ | |_| |_| |_|_|\___/ \__,_|\__,_|\___|_| + """ __title__ = "mloader" __description__ = "Command-line tool to download manga from mangaplus" __url__ = "https://github.com/hurlenko/mloader" -__version__ = "1.1.12" +__version__ = "2.1.2" __license__ = "GPLv3" diff --git a/mloader/application/__init__.py b/mloader/application/__init__.py new file mode 100644 index 0000000..61d7b1b --- /dev/null +++ b/mloader/application/__init__.py @@ -0,0 +1 @@ +"""Application-layer use cases for CLI orchestration.""" diff --git a/mloader/application/discovery.py b/mloader/application/discovery.py new file mode 100644 index 0000000..821da52 --- /dev/null +++ b/mloader/application/discovery.py @@ -0,0 +1,105 @@ +"""Application use cases for title discovery.""" + +from __future__ import annotations + +from collections.abc import Collection + +import requests + +from mloader.application.errors import DiscoveryError +from mloader.application.ports import TitleDiscoveryGateway +from mloader.domain.requests import DiscoveryRequest +from mloader.errors import APIResponseError + + +def discover_title_ids( + request: DiscoveryRequest, + *, + gateway: TitleDiscoveryGateway, +) -> tuple[list[int], list[str]]: + """Discover title IDs and return ``(ids, notices)`` for CLI output.""" + notices: list[str] = [] + allowed_languages = gateway.parse_language_filters(request.languages) + title_ids: list[int] = [] + + try: + title_ids = gateway.collect_title_ids_from_api( + request.title_index_endpoint, + id_length=request.id_length, + allowed_languages=allowed_languages, + capture_api_dir=request.capture_api_dir, + ) + except requests.RequestException as exc: + if allowed_languages is not None: + raise DiscoveryError( + "Language filtering requires API title-index access, but the API request failed: " + f"{exc}" + ) from exc + notices.append(f"API title-index fetch failed: {exc}") + except APIResponseError as exc: + if allowed_languages is not None: + raise DiscoveryError( + "Language filtering requires API title-index access, but the API response " + f"was unusable: {exc}" + ) from exc + notices.append(f"API title-index payload unusable: {exc}") + + if not title_ids and allowed_languages is None: + try: + title_ids = gateway.collect_title_ids( + request.pages, + id_length=request.id_length, + ) + except requests.RequestException as exc: + if not request.browser_fallback: + raise DiscoveryError(f"Failed to fetch title pages: {exc}") from exc + notices.append(f"Static fetch failed: {exc}. Retrying with browser fallback.") + + if not title_ids and request.browser_fallback and allowed_languages is None: + try: + title_ids = gateway.collect_title_ids_with_browser( + request.pages, + id_length=request.id_length, + ) + except RuntimeError as exc: + raise DiscoveryError(str(exc)) from exc + except Exception as exc: # pragma: no cover - defensive fallback wrapper + raise DiscoveryError(f"Browser fallback failed: {exc}") from exc + + if not title_ids and allowed_languages is not None: + selected_languages = ", ".join(language.lower() for language in request.languages) + raise DiscoveryError( + f"No title IDs found for selected language filter(s): {selected_languages}." + ) + + if not title_ids: + raise DiscoveryError( + "No title IDs found on configured list pages. " + "Try enabling browser fallback or verify page access." + ) + + return title_ids, notices + + +def summarize_discovery(title_ids: Collection[int]) -> str: + """Return human-readable summary for discovered title IDs.""" + return f"Discovered {len(title_ids)} title ID(s)." + + +def format_discovered_ids(title_ids: Collection[int]) -> str: + """Return a space-separated title ID list for CLI printing.""" + return " ".join(str(title_id) for title_id in title_ids) + + +def verify_discovery_flags( + *, + download_all_titles: bool, + list_only: bool, + languages: Collection[str], +) -> str | None: + """Return validation error message for discovery-only flags, if any.""" + if list_only and not download_all_titles: + return "--list-only requires --all." + if languages and not download_all_titles: + return "--language requires --all." + return None diff --git a/mloader/application/downloads.py b/mloader/application/downloads.py new file mode 100644 index 0000000..4fb0c43 --- /dev/null +++ b/mloader/application/downloads.py @@ -0,0 +1,131 @@ +"""Application use cases for download execution.""" + +from __future__ import annotations + +from collections.abc import Mapping + +import requests + +from mloader.application.errors import DownloadInterrupted, ExternalDependencyError +from mloader.application.ports import DownloadRuntimeFactory, ExporterClass +from mloader.domain.requests import DownloadRequest, DownloadSummary, EffectiveOutputFormat +from mloader.errors import APIResponseError +from mloader.errors import DownloadInterruptedError +from mloader.types import ChapterLike, ExporterFactoryLike, ExporterLike, TitleLike + + +def resolve_exporter( + request: DownloadRequest, + *, + raw_exporter: ExporterClass, + pdf_exporter: ExporterClass, + cbz_exporter: ExporterClass, +) -> tuple[ExporterClass, EffectiveOutputFormat]: + """Resolve exporter class and effective output format from request options.""" + if request.raw: + return raw_exporter, "raw" + if request.output_format == "pdf": + return pdf_exporter, "pdf" + return cbz_exporter, "cbz" + + +def build_exporter_factory( + request: DownloadRequest, + exporter_class: ExporterClass, +) -> ExporterFactoryLike: + """Build the per-chapter exporter factory passed into the download runtime.""" + + def create_exporter( + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + return exporter_class( + destination=request.out_dir, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=request.chapter_title, + add_chapter_subdir=request.chapter_subdir, + add_language_to_chapter_name=request.filename_style == "new", + ) + + return create_exporter + + +def execute_download( + request: DownloadRequest, + *, + loader_factory: DownloadRuntimeFactory, + raw_exporter: ExporterClass, + pdf_exporter: ExporterClass, + cbz_exporter: ExporterClass, +) -> DownloadSummary: + """Execute the configured download request via the provided factories.""" + exporter_class, effective_output_format = resolve_exporter( + request, + raw_exporter=raw_exporter, + pdf_exporter=pdf_exporter, + cbz_exporter=cbz_exporter, + ) + exporter_factory = build_exporter_factory(request, exporter_class) + + loader = loader_factory( + exporter_factory, + request.quality, + request.split, + request.meta, + request.cover, + destination=request.out_dir, + output_format=effective_output_format, + capture_api_dir=request.capture_api_dir, + filename_style=request.filename_style, + rename_existing_filenames=request.rename_existing_filenames, + resume=request.resume, + manifest_reset=request.manifest_reset, + cover_format=request.cover_format, + ) + try: + summary = loader.download( + title_ids=request.titles or None, + chapter_numbers=request.chapters or None, + chapter_ids=request.chapter_ids or None, + min_chapter=request.begin, + max_chapter=request.max_chapter, + last_chapter=request.last, + ) + except DownloadInterruptedError as exc: + raise DownloadInterrupted(exc.summary) from exc + except (requests.RequestException, APIResponseError) as exc: + raise ExternalDependencyError(f"Download request failed: {exc}") from exc + + if isinstance(summary, DownloadSummary): + return summary + return DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def to_chapter_id_debug_map( + request: DownloadRequest, +) -> Mapping[str, int | bool | str | None]: + """Return minimal structured fields useful for debug logging.""" + return { + "target_titles": len(request.titles), + "target_chapters": len(request.chapters), + "target_chapter_ids": len(request.chapter_ids), + "begin": request.begin, + "end": request.end, + "raw": request.raw, + "format": request.output_format, + "cover": request.cover, + "cover_format": request.cover_format, + "resume": request.resume, + "manifest_reset": request.manifest_reset, + "capture_api": request.capture_api_dir is not None, + "run_report": request.run_report_path is not None, + } diff --git a/mloader/application/errors.py b/mloader/application/errors.py new file mode 100644 index 0000000..aaf214e --- /dev/null +++ b/mloader/application/errors.py @@ -0,0 +1,35 @@ +"""Application-layer errors shared by CLI use cases.""" + +from __future__ import annotations + +from mloader.domain.requests import DownloadSummary +from mloader.errors import FailureKind + + +class ApplicationError(RuntimeError): + """Base class for application-level execution failures.""" + + error_kind: FailureKind = "internal_bug" + + +class DiscoveryError(ApplicationError): + """Raised when title discovery for ``--all`` cannot produce IDs.""" + + error_kind: FailureKind = "external_dependency" + + +class ExternalDependencyError(ApplicationError): + """Raised when external systems fail during download execution.""" + + error_kind: FailureKind = "external_dependency" + + +class DownloadInterrupted(ExternalDependencyError): + """Raised when user interrupts download while preserving partial summary.""" + + error_kind: FailureKind = "interrupted" + + def __init__(self, summary: DownloadSummary) -> None: + """Store partial summary generated before interruption.""" + super().__init__("Download interrupted by user.") + self.summary = summary diff --git a/mloader/application/ports.py b/mloader/application/ports.py new file mode 100644 index 0000000..d30c9eb --- /dev/null +++ b/mloader/application/ports.py @@ -0,0 +1,106 @@ +"""Application-layer ports for runtime and infrastructure adapters.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + +from mloader.domain.requests import ( + CoverFormat, + DownloadSummary, + EffectiveOutputFormat, + FilenameStyle, +) +from mloader.types import ChapterLike, ExporterFactoryLike, ExporterLike, TitleLike + + +class ExporterClass(Protocol): + """Port for exporter classes selected by application output options.""" + + def __call__( + self, + *, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + ) -> ExporterLike: + """Create an exporter instance for one chapter.""" + + +class TitleDiscoveryGateway(Protocol): + """Port for title discovery backends used by the ``--all`` command.""" + + def parse_language_filters(self, languages: Sequence[str]) -> set[int] | None: + """Map user-facing language names to API language codes.""" + + def collect_title_ids_from_api( + self, + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + capture_api_dir: str | None = None, + ) -> list[int]: + """Collect title IDs from the title-index API endpoint.""" + + def collect_title_ids( + self, + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + ) -> list[int]: + """Collect title IDs from static page HTML.""" + + def collect_title_ids_with_browser( + self, + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, + ) -> list[int]: + """Collect title IDs from browser-rendered list pages.""" + + +class DownloadRuntime(Protocol): + """Port for an executable download runtime.""" + + def download( + self, + *, + title_ids: set[int] | frozenset[int] | None = None, + chapter_numbers: set[int] | frozenset[int] | None = None, + chapter_ids: set[int] | frozenset[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary | None: + """Execute one download run and return a summary when available.""" + + +class DownloadRuntimeFactory(Protocol): + """Port for constructing download runtimes from application options.""" + + def __call__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool = False, + *, + destination: str = "mloader_downloads", + output_format: EffectiveOutputFormat = "cbz", + capture_api_dir: str | None = None, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + resume: bool = True, + manifest_reset: bool = False, + cover_format: CoverFormat = "png", + ) -> DownloadRuntime: + """Build a runtime capable of executing a download request.""" diff --git a/mloader/application/requests.py b/mloader/application/requests.py new file mode 100644 index 0000000..d34da2a --- /dev/null +++ b/mloader/application/requests.py @@ -0,0 +1,92 @@ +"""Application request-model construction.""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import cast + +from mloader.domain.requests import ( + ApiOutputFormat, + COVER_FORMATS, + CoverFormat, + DownloadRequest, + DiscoveryRequest, + FilenameStyle, +) + + +def build_download_request( + *, + out_dir: str, + raw: bool, + output_format: str, + capture_api_dir: str | None, + quality: str, + split: bool, + begin: int, + end: int | None, + last: bool, + chapter_title: bool, + chapter_subdir: bool, + meta: bool, + cover: bool, + cover_format: str, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + resume: bool, + manifest_reset: bool, + chapters: Collection[int] | None, + chapter_ids: Collection[int] | None, + titles: Collection[int] | None, + run_report_path: str | None = None, +) -> DownloadRequest: + """Create a typed download request from CLI-normalized values.""" + api_output_format: ApiOutputFormat = "pdf" if output_format == "pdf" else "cbz" + normalized_cover_format = cover_format.lower() + if normalized_cover_format not in COVER_FORMATS: + raise ValueError(f"Unsupported cover format: {cover_format}") + typed_cover_format = cast(CoverFormat, normalized_cover_format) + return DownloadRequest( + out_dir=out_dir, + raw=raw, + output_format=api_output_format, + capture_api_dir=capture_api_dir, + quality=quality, + split=split, + begin=begin, + end=end, + last=last, + chapter_title=chapter_title, + chapter_subdir=chapter_subdir, + meta=meta, + cover=cover, + cover_format=typed_cover_format, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + resume=resume, + manifest_reset=manifest_reset, + chapters=frozenset(chapters or set()), + chapter_ids=frozenset(chapter_ids or set()), + titles=frozenset(titles or set()), + run_report_path=run_report_path, + ) + + +def build_discovery_request( + *, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, + capture_api_dir: str | None = None, +) -> DiscoveryRequest: + """Create a typed discovery request from CLI-normalized values.""" + return DiscoveryRequest( + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + capture_api_dir=capture_api_dir, + ) diff --git a/mloader/cli/__init__.py b/mloader/cli/__init__.py new file mode 100644 index 0000000..a786c49 --- /dev/null +++ b/mloader/cli/__init__.py @@ -0,0 +1 @@ +"""CLI package exports.""" diff --git a/mloader/cli/capture_command.py b/mloader/cli/capture_command.py new file mode 100644 index 0000000..e6f743f --- /dev/null +++ b/mloader/cli/capture_command.py @@ -0,0 +1,36 @@ +"""Capture-verification CLI command behavior.""" + +from __future__ import annotations + +from collections.abc import Callable + +from mloader.cli.command_errors import fail +from mloader.cli.exit_codes import VALIDATION_ERROR +from mloader.cli.presenter import CliPresenter +from mloader.infrastructure.mangaplus.capture_verify import ( + CaptureVerificationError, + CaptureVerificationSummary, +) + + +def run_capture_verification_mode( + *, + verify_capture_schema_dir: str, + verify_capture_baseline_dir: str | None, + presenter: CliPresenter, + verify_capture_schema_func: Callable[[str], CaptureVerificationSummary], + verify_capture_schema_against_baseline_func: Callable[[str, str], CaptureVerificationSummary], +) -> CaptureVerificationSummary: + """Run capture schema verification command mode and return summary.""" + try: + if verify_capture_baseline_dir: + summary = verify_capture_schema_against_baseline_func( + verify_capture_schema_dir, + verify_capture_baseline_dir, + ) + else: + summary = verify_capture_schema_func(verify_capture_schema_dir) + except CaptureVerificationError as exc: + fail(str(exc), presenter=presenter, exit_code=VALIDATION_ERROR) + + return summary diff --git a/mloader/cli/command_defaults.py b/mloader/cli/command_defaults.py new file mode 100644 index 0000000..6bdaf3b --- /dev/null +++ b/mloader/cli/command_defaults.py @@ -0,0 +1,89 @@ +"""Default dependency bindings for CLI command behavior.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import cast + +from mloader.application.ports import TitleDiscoveryGateway +from mloader.cli import capture_command, discovery_command +from mloader.cli.presenter import CliPresenter +from mloader.cli.run_report import write_run_report_if_requested as write_run_report +from mloader.domain.requests import DownloadRequest, DownloadSummary +from mloader.infrastructure.mangaplus import title_discovery +from mloader.infrastructure.mangaplus.capture_verify import ( + CaptureVerificationSummary, + verify_capture_schema, + verify_capture_schema_against_baseline, +) + + +def write_run_report_if_requested( + request: DownloadRequest, + *, + run_id: str, + started_at: datetime, + status: str, + exit_code: int, + discovery: dict[str, int] | None, + summary: DownloadSummary | None, + error_message: str | None, + subscription_access_failures: int = 0, + completed_at: datetime | None = None, +) -> None: + """Write a run report using the production filesystem adapter.""" + write_run_report( + request, + run_id=run_id, + started_at=started_at, + completed_at=completed_at, + status=status, + exit_code=exit_code, + discovery=discovery, + summary=summary, + error_message=error_message, + subscription_access_failures=subscription_access_failures, + path_type=Path, + ) + + +def run_capture_verification_mode( + *, + verify_capture_schema_dir: str, + verify_capture_baseline_dir: str | None, + presenter: CliPresenter, +) -> CaptureVerificationSummary: + """Run capture verification using the production verifier functions.""" + return capture_command.run_capture_verification_mode( + verify_capture_schema_dir=verify_capture_schema_dir, + verify_capture_baseline_dir=verify_capture_baseline_dir, + presenter=presenter, + verify_capture_schema_func=verify_capture_schema, + verify_capture_schema_against_baseline_func=verify_capture_schema_against_baseline, + ) + + +def resolve_all_mode_targets( + *, + request: DownloadRequest, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, + list_only: bool, + presenter: CliPresenter, +) -> tuple[DownloadRequest | None, dict[str, int] | None]: + """Resolve ``--all`` targets using the production title-discovery gateway.""" + return discovery_command.resolve_all_mode_targets( + request=request, + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + list_only=list_only, + presenter=presenter, + discovery_gateway=cast(TitleDiscoveryGateway, title_discovery.DEFAULT_GATEWAY), + ) diff --git a/mloader/cli/command_errors.py b/mloader/cli/command_errors.py new file mode 100644 index 0000000..b46c93b --- /dev/null +++ b/mloader/cli/command_errors.py @@ -0,0 +1,40 @@ +"""CLI exception and failure helpers.""" + +from __future__ import annotations + +from typing import NoReturn + +import click + +from mloader.cli.presenter import CliPresenter + + +class MloaderCliError(click.ClickException): + """Click exception that carries deterministic exit code mapping.""" + + def __init__(self, message: str, *, exit_code: int) -> None: + """Store message and deterministic process exit code.""" + super().__init__(message) + self.exit_code = exit_code + + +def fail( + message: str, + *, + presenter: CliPresenter, + exit_code: int, + details: dict[str, object] | None = None, +) -> NoReturn: + """Abort command execution with deterministic exit code and optional JSON error.""" + if presenter.json_output: + payload: dict[str, object] = { + "status": "error", + "exit_code": exit_code, + "message": message, + } + if details: + payload.update(details) + presenter.emit_json(payload) + raise click.exceptions.Exit(exit_code) + + raise MloaderCliError(message, exit_code=exit_code) diff --git a/mloader/cli/command_requests.py b/mloader/cli/command_requests.py new file mode 100644 index 0000000..7aa52f9 --- /dev/null +++ b/mloader/cli/command_requests.py @@ -0,0 +1,105 @@ +"""Translate Click-normalized CLI options into application request models.""" + +from __future__ import annotations + +from collections.abc import Collection + +import click +from click.core import ParameterSource + +from mloader.application import discovery as discovery_use_cases +from mloader.application import requests as app_requests +from mloader.domain.requests import DownloadRequest, DiscoveryRequest, FilenameStyle + + +def parameter_was_provided(ctx: click.Context, parameter_name: str) -> bool: + """Return whether a Click parameter was provided explicitly on the command line.""" + return ctx.get_parameter_source(parameter_name) is ParameterSource.COMMANDLINE + + +def build_download_request( + ctx: click.Context, + *, + out_dir: str, + raw: bool, + output_format: str, + capture_api_dir: str | None, + quality: str, + split: bool, + begin: int, + end: int | None, + last: bool, + chapter_title: bool, + chapter_subdir: bool, + meta: bool, + cover: bool, + cover_format: str, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + resume: bool, + manifest_reset: bool, + chapters: Collection[int] | None, + chapter_ids: Collection[int] | None, + titles: Collection[int] | None, + run_report_path: str | None, +) -> DownloadRequest: + """Build a download request from CLI options while preserving CLI-specific semantics.""" + cover_enabled = cover or parameter_was_provided(ctx, "cover_format") + return app_requests.build_download_request( + out_dir=out_dir, + raw=raw, + output_format=output_format, + capture_api_dir=capture_api_dir, + quality=quality, + split=split, + begin=begin, + end=end, + last=last, + chapter_title=chapter_title, + chapter_subdir=chapter_subdir, + meta=meta, + cover=cover_enabled, + cover_format=cover_format, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + resume=resume, + manifest_reset=manifest_reset, + chapters=chapters, + chapter_ids=chapter_ids, + titles=titles, + run_report_path=run_report_path, + ) + + +def validate_discovery_flags( + *, + download_all_titles: bool, + list_only: bool, + languages: Collection[str], +) -> str | None: + """Validate discovery-only flag combinations from CLI options.""" + return discovery_use_cases.verify_discovery_flags( + download_all_titles=download_all_titles, + list_only=list_only, + languages=languages, + ) + + +def build_discovery_request( + *, + request: DownloadRequest, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, +) -> DiscoveryRequest: + """Build a discovery request from CLI options and the active download request.""" + return app_requests.build_discovery_request( + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + capture_api_dir=request.capture_api_dir, + ) diff --git a/mloader/cli/config.py b/mloader/cli/config.py new file mode 100644 index 0000000..7e9da4f --- /dev/null +++ b/mloader/cli/config.py @@ -0,0 +1,32 @@ +"""Logging configuration helpers for CLI execution.""" + +from __future__ import annotations + +import logging +import sys +from typing import TextIO + + +def setup_logging( + *, + level: int = logging.INFO, + stream: TextIO | None = None, +) -> None: + """Configure application logging for console output.""" + for logger_name in ("requests", "urllib3"): + logging.getLogger(logger_name).setLevel(logging.WARNING) + + stream_handler = logging.StreamHandler(stream or sys.stderr) + logging.basicConfig( + handlers=[stream_handler], + format=("{asctime:^} | {levelname: ^8} | {filename: ^14} {lineno: <4} | {message}"), + style="{", + datefmt="%d.%m.%Y %H:%M:%S", + level=level, + force=True, + ) + + +def get_logger(name: str | None = None) -> logging.Logger: + """Return a logger for ``name`` or the root logger when omitted.""" + return logging.getLogger(name) diff --git a/mloader/cli/discovery_command.py b/mloader/cli/discovery_command.py new file mode 100644 index 0000000..4361f0e --- /dev/null +++ b/mloader/cli/discovery_command.py @@ -0,0 +1,71 @@ +"""All-title discovery CLI command behavior.""" + +from __future__ import annotations + +from mloader.application import discovery as discovery_use_cases +from mloader.application.errors import DiscoveryError +from mloader.application.ports import TitleDiscoveryGateway +from mloader.cli import command_requests +from mloader.cli.command_errors import fail +from mloader.cli.exit_codes import EXTERNAL_FAILURE, SUCCESS +from mloader.cli.presenter import CliPresenter +from mloader.domain.requests import DownloadRequest + + +def resolve_all_mode_targets( + *, + request: DownloadRequest, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, + list_only: bool, + presenter: CliPresenter, + discovery_gateway: TitleDiscoveryGateway, +) -> tuple[DownloadRequest | None, dict[str, int] | None]: + """Resolve title targets for ``--all`` mode and optionally print-only IDs.""" + discovery_request = command_requests.build_discovery_request( + request=request, + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + ) + try: + discovered_title_ids, notices = discovery_use_cases.discover_title_ids( + discovery_request, + gateway=discovery_gateway, + ) + except DiscoveryError as exc: + fail(str(exc), presenter=presenter, exit_code=EXTERNAL_FAILURE) + + presenter.emit_notices(notices) + + if presenter.json_output and list_only: + presenter.emit_json( + { + "status": "ok", + "mode": "all_list_only", + "exit_code": SUCCESS, + "count": len(discovered_title_ids), + "title_ids": discovered_title_ids, + } + ) + return None, None + + if presenter.emits_human_output: + presenter.emit_discovery_summary(discovered_title_ids) + if list_only: + presenter.emit_discovery_ids(discovered_title_ids) + return None, None + + if presenter.quiet and list_only: + return None, None + + updated_request = request.with_additional_titles(set(discovered_title_ids)) + metadata = { + "discovered_titles": len(discovered_title_ids), + } + return updated_request, metadata diff --git a/mloader/cli/download_command.py b/mloader/cli/download_command.py new file mode 100644 index 0000000..f74c17f --- /dev/null +++ b/mloader/cli/download_command.py @@ -0,0 +1,170 @@ +"""Download execution CLI command behavior.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from mloader.application import downloads as download_use_cases +from mloader.application.errors import DownloadInterrupted, ExternalDependencyError +from mloader.application.ports import DownloadRuntimeFactory, ExporterClass +from mloader.cli.command_errors import fail +from mloader.cli.error_mapping import cli_failure_mapping, partial_download_failure_mapping +from mloader.cli.exit_codes import SUCCESS +from mloader.cli.presenter import CliPresenter +from mloader.cli.run_report import ( + Clock, + RunIdFactory, + new_run_id, + summary_payload, + utc_now, + write_run_report_if_requested, +) +from mloader.domain.requests import DownloadRequest +from mloader.errors import SubscriptionRequiredError + +log = logging.getLogger(__name__) + + +def run_download_request( + request: DownloadRequest, + *, + presenter: CliPresenter, + discovery_metadata: dict[str, int] | None, + loader_factory: DownloadRuntimeFactory, + raw_exporter: ExporterClass, + pdf_exporter: ExporterClass, + cbz_exporter: ExporterClass, + write_run_report: Callable[..., None] = write_run_report_if_requested, + run_id_factory: RunIdFactory = new_run_id, + clock: Clock = utc_now, +) -> None: + """Execute a prepared download request and render the final CLI result.""" + run_id = run_id_factory() + run_started_at = clock() + log.info("Started export") + log.debug("Download request: %s", download_use_cases.to_chapter_id_debug_map(request)) + + try: + download_summary = download_use_cases.execute_download( + request, + loader_factory=loader_factory, + raw_exporter=raw_exporter, + pdf_exporter=pdf_exporter, + cbz_exporter=cbz_exporter, + ) + except DownloadInterrupted as exc: + failure = cli_failure_mapping(exc) + presenter.emit_download_summary(exc.summary) + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status=failure.report_status, + exit_code=failure.exit_code, + discovery=discovery_metadata, + summary=exc.summary, + error_message="Download interrupted by user.", + ) + fail( + "Download interrupted by user.", + presenter=presenter, + exit_code=failure.exit_code, + details={"summary": summary_payload(exc.summary)}, + ) + except SubscriptionRequiredError as exc: + failure = cli_failure_mapping(exc) + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status=failure.report_status, + exit_code=failure.exit_code, + discovery=discovery_metadata, + summary=None, + error_message=str(exc), + subscription_access_failures=failure.subscription_access_failures, + ) + fail(str(exc), presenter=presenter, exit_code=failure.exit_code) + except ExternalDependencyError as exc: + failure = cli_failure_mapping(exc) + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status=failure.report_status, + exit_code=failure.exit_code, + discovery=discovery_metadata, + summary=None, + error_message=str(exc), + ) + fail(str(exc), presenter=presenter, exit_code=failure.exit_code) + except Exception as exc: + failure = cli_failure_mapping(exc) + if not presenter.json_output: + log.exception("Failed to download manga") + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status=failure.report_status, + exit_code=failure.exit_code, + discovery=discovery_metadata, + summary=None, + error_message=f"Download failed: {exc}", + ) + fail("Download failed", presenter=presenter, exit_code=failure.exit_code) + + presenter.emit_download_summary(download_summary) + resolved_summary_payload = summary_payload(download_summary) + if download_summary.has_failures: + failure = partial_download_failure_mapping() + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status=failure.report_status, + exit_code=failure.exit_code, + discovery=discovery_metadata, + summary=download_summary, + error_message=f"Download completed with {download_summary.failed} failed chapter(s).", + ) + fail( + f"Download completed with {download_summary.failed} failed chapter(s).", + presenter=presenter, + exit_code=failure.exit_code, + details={"summary": resolved_summary_payload}, + ) + + log.info("SUCCESS") + write_run_report( + request, + run_id=run_id, + started_at=run_started_at, + completed_at=clock(), + status="ok", + exit_code=SUCCESS, + discovery=discovery_metadata, + summary=download_summary, + error_message=None, + ) + if presenter.json_output: + presenter.emit_json( + { + "status": "ok", + "mode": "download", + "exit_code": SUCCESS, + "targets": { + "titles": len(request.titles), + "chapters": len(request.chapters), + "chapter_ids": len(request.chapter_ids), + }, + "discovery": discovery_metadata, + "summary": resolved_summary_payload, + } + ) diff --git a/mloader/cli/error_mapping.py b/mloader/cli/error_mapping.py new file mode 100644 index 0000000..ac598cb --- /dev/null +++ b/mloader/cli/error_mapping.py @@ -0,0 +1,40 @@ +"""Map typed runtime/application failures to CLI/report behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG +from mloader.errors import FailureKind + +ReportStatus = Literal["error"] + + +@dataclass(frozen=True, slots=True) +class CliFailureMapping: + """CLI boundary mapping for a normalized failure kind.""" + + error_kind: FailureKind + exit_code: int + report_status: ReportStatus = "error" + subscription_access_failures: int = 0 + + +def cli_failure_mapping(error: BaseException) -> CliFailureMapping: + """Return CLI/report behavior for ``error`` based on its failure kind.""" + error_kind = getattr(error, "error_kind", "internal_bug") + if error_kind == "subscription_required": + return CliFailureMapping( + error_kind="subscription_required", + exit_code=EXTERNAL_FAILURE, + subscription_access_failures=1, + ) + if error_kind in {"external_dependency", "interrupted"}: + return CliFailureMapping(error_kind=error_kind, exit_code=EXTERNAL_FAILURE) + return CliFailureMapping(error_kind="internal_bug", exit_code=INTERNAL_BUG) + + +def partial_download_failure_mapping() -> CliFailureMapping: + """Return CLI/report behavior for completed runs with failed chapters.""" + return CliFailureMapping(error_kind="external_dependency", exit_code=EXTERNAL_FAILURE) diff --git a/mloader/cli/examples.py b/mloader/cli/examples.py new file mode 100644 index 0000000..cf94575 --- /dev/null +++ b/mloader/cli/examples.py @@ -0,0 +1,190 @@ +"""Curated CLI example catalog used by ``--show-examples`` output mode.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CliExample: + """One runnable CLI example with short context.""" + + title: str + command: str + description: str + + +_EXAMPLE_SPECS: tuple[tuple[str, str, str], ...] = ( + ( + "Show this complete example catalog", + "{prog} --show-examples", + "Print all curated examples and exit.", + ), + ( + "Print CLI version", + "{prog} --version", + "Check installed mloader version.", + ), + ( + "Download from a chapter viewer URL", + "{prog} https://mangaplus.shueisha.co.jp/viewer/1024959", + "Default output format is CBZ.", + ), + ( + "Download all chapters from a title URL as PDF to custom directory", + "{prog} https://mangaplus.shueisha.co.jp/titles/100312 --out ./downloads --format pdf", + "Uses positional URL target plus output format and destination overrides.", + ), + ( + "Target by title and chapter number", + "{prog} --title 100312 --chapter 12", + "Resolve chapter number to API chapter ID within selected titles.", + ), + ( + "Target by explicit chapter API ID", + "{prog} --chapter-id 1024959", + "Useful when a viewer URL or known API chapter ID is available.", + ), + ( + "Download multiple titles with chapter range bounds", + "{prog} --title 100312 --title 100315 --begin 3 --end 25", + "Restrict downloads to chapter numbers within the selected interval.", + ), + ( + "Download only the latest chapter per title", + "{prog} --title 100312 --last", + "Use latest-only mode for quick update checks.", + ), + ( + "Save raw images with chapter title and chapter subdirectories", + "{prog} --title 100312 --raw --chapter-title --chapter-subdir", + "Useful when post-processing images externally.", + ), + ( + "Tune quality and request split pages", + "{prog} --title 100312 --quality low --split", + "Lower quality and split behavior can reduce transfer size.", + ), + ( + "Use legacy or new chapter filename style", + "{prog} --title 100312 --filename-style new", + "Include language tags in chapter filenames using the new style.", + ), + ( + "Rename previously downloaded legacy filenames", + "{prog} --title 100312 --filename-style new --rename-existing-filenames", + "Convert existing chapter files to the selected filename style.", + ), + ( + "Export title metadata JSON", + "{prog} --title 100312 --meta", + "Writes title metadata and per-chapter metadata into output directory.", + ), + ( + "Download title cover image as PNG", + "{prog} --title 100312 --cover", + "Stores one cover image per title as cover.png.", + ), + ( + "Download title cover image as WEBP", + "{prog} --title 100312 --cover-format webp", + "Stores one cover image per title as cover.webp.", + ), + ( + "Capture API payloads for regression analysis", + "{prog} --title 100312 --capture-api ./capture/new-run", + "Stores protobuf payloads and metadata for future schema checks.", + ), + ( + "Write a cron-friendly run report", + "{prog} --title 100312 --run-report ./reports/latest-run.json", + "Stores selected args, timings, summary counters, and exporter safety metadata.", + ), + ( + "Verify capture schema fields", + "{prog} --verify-capture-schema ./capture/new-run", + "Checks captured payloads against required decode fields and exits.", + ), + ( + "Compare capture schema against baseline fixtures", + "{prog} --verify-capture-schema ./capture/new-run --verify-capture-baseline ./tests/fixtures/api_captures/baseline", + "Detects schema drift between current captures and baseline captures.", + ), + ( + "Discover and download all titles", + "{prog} --all --format pdf", + "Bulk mode starts with API-first discovery and downloads all found titles.", + ), + ( + "List discovered titles only (no download)", + "{prog} --all --list-only", + "Print title IDs from discovery and exit.", + ), + ( + "Restrict bulk discovery to selected languages", + "{prog} --all --language english --language spanish", + "Language filtering applies to API-first discovery payloads.", + ), + ( + "Restrict bulk discovery by title ID length", + "{prog} --all --id-length 6", + "Keeps only IDs with exact digit length.", + ), + ( + "Use custom list pages for fallback scraping", + "{prog} --all --page https://mangaplus.shueisha.co.jp/manga_list/ongoing --page https://mangaplus.shueisha.co.jp/manga_list/completed", + "Useful if you want to scope fallback page crawling.", + ), + ( + "Use custom API title-index endpoint", + "{prog} --all --title-index-endpoint https://jumpg-api.tokyo-cdn.com/api/title_list/allV2", + "Overrides API endpoint used for title discovery.", + ), + ( + "Disable browser fallback in bulk mode", + "{prog} --all --no-browser-fallback", + "Fail fast when API/static fetch produces no IDs.", + ), + ( + "Enable browser fallback explicitly", + "{prog} --all --browser-fallback", + "For clarity in scripts where explicit fallback behavior is preferred.", + ), + ( + "Emit machine-readable JSON output", + "{prog} --json --chapter-id 1024959", + "Structured output mode for automation and scripting.", + ), + ( + "Suppress non-error human output", + "{prog} --quiet --chapter-id 1024959", + "Keeps stdout quieter in interactive shells.", + ), + ( + "Enable debug logging", + "{prog} --verbose --chapter-id 1024959", + "Repeat --verbose for additional detail (for example: -vv).", + ), + ( + "Retry failed chapters from manifest while skipping completed", + "{prog} --title 100312 --resume", + "Explicitly enables manifest-based resume behavior.", + ), + ( + "Disable resume and reset manifest state before download", + "{prog} --title 100312 --no-resume --manifest-reset", + "Forces fresh run behavior regardless of prior manifest state.", + ), +) + + +def build_cli_examples(*, prog_name: str) -> tuple[CliExample, ...]: + """Return full example catalog with ``prog_name`` substituted into commands.""" + return tuple( + CliExample( + title=title, + command=command.format(prog=prog_name), + description=description, + ) + for title, command, description in _EXAMPLE_SPECS + ) diff --git a/mloader/cli/exit_codes.py b/mloader/cli/exit_codes.py new file mode 100644 index 0000000..d8c2e80 --- /dev/null +++ b/mloader/cli/exit_codes.py @@ -0,0 +1,7 @@ +"""Deterministic process exit-code mapping for the CLI.""" + +SUCCESS = 0 +USER_ERROR = 2 +VALIDATION_ERROR = 3 +EXTERNAL_FAILURE = 4 +INTERNAL_BUG = 5 diff --git a/mloader/cli/main.py b/mloader/cli/main.py new file mode 100644 index 0000000..376bf4e --- /dev/null +++ b/mloader/cli/main.py @@ -0,0 +1,454 @@ +"""Command-line interface definition for mloader.""" + +from __future__ import annotations + +import click + +from mloader import __version__ as about +from mloader.cli import command_requests +from mloader.cli import examples as cli_examples +from mloader.cli.command_defaults import ( + resolve_all_mode_targets, + run_capture_verification_mode, + write_run_report_if_requested, +) +from mloader.cli.command_errors import fail +from mloader.cli.config import setup_logging +from mloader.cli.download_command import run_download_request +from mloader.cli.exit_codes import SUCCESS, VALIDATION_ERROR +from mloader.cli.presenter import CliPresenter +from mloader.cli.runtime_options import SUPPORTED_AUTH_OS_VALUES, resolve_log_level +from mloader.cli.validators import validate_ids, validate_urls +from mloader.config import AUTH_SETTINGS +from mloader.domain.requests import COVER_FORMATS, FilenameStyle +from mloader.exporters import CBZExporter, PDFExporter, RawExporter +from mloader.manga_loader.init import MangaLoader +from mloader.infrastructure.mangaplus import title_discovery + + +@click.command( + help=about.__description__, +) +@click.version_option( + about.__version__, + prog_name=about.__title__, + message="%(prog)s by Hurlenko, version %(version)s\nCheck {url} for more info".format( + url=about.__url__ + ), +) +@click.option( + "--json", + "json_output", + is_flag=True, + default=False, + show_default=True, + help="Emit structured JSON output to stdout", +) +@click.option( + "--quiet", + is_flag=True, + default=False, + show_default=True, + help="Suppress non-error human-readable output", +) +@click.option( + "--show-examples", + is_flag=True, + default=False, + show_default=True, + help="Print exhaustive command examples and exit", +) +@click.option( + "--verbose", + "-v", + count=True, + help="Increase logging verbosity (repeatable)", +) +@click.option( + "--out", + "-o", + "out_dir", + type=click.Path(exists=False, writable=True), + metavar="", + default="mloader_downloads", + show_default=True, + help="Output directory for downloads", + envvar="MLOADER_EXTRACT_OUT_DIR", +) +@click.option( + "--verify-capture-schema", + "verify_capture_schema_dir", + type=click.Path(exists=True, file_okay=False, readable=True), + metavar="", + help="Verify captured API payloads against required response schema fields and exit", +) +@click.option( + "--verify-capture-baseline", + "verify_capture_baseline_dir", + type=click.Path(exists=True, file_okay=False, readable=True), + metavar="", + help="Compare verified capture schema signatures against a baseline capture directory", +) +@click.option( + "--all", + "download_all_titles", + is_flag=True, + default=False, + show_default=True, + help="Discover all available titles and download them", +) +@click.option( + "--page", + "pages", + multiple=True, + default=title_discovery.DEFAULT_LIST_PAGES, + show_default=True, + help="MangaPlus list page to scrape for title links (repeatable)", +) +@click.option( + "--title-index-endpoint", + type=str, + default=title_discovery.DEFAULT_TITLE_INDEX_ENDPOINT, + show_default=True, + help="MangaPlus mobile API endpoint used for API-first title discovery", + envvar="MLOADER_TITLE_INDEX_ENDPOINT", +) +@click.option( + "--id-length", + type=click.IntRange(min=1), + default=None, + help="If set, keep only title IDs with this exact digit length", +) +@click.option( + "--language", + "languages", + multiple=True, + type=click.Choice(title_discovery.LANGUAGE_FILTER_CHOICES, case_sensitive=False), + help="Restrict --all discovery to one or more languages (repeatable)", +) +@click.option( + "--list-only", + is_flag=True, + default=False, + show_default=True, + help="Only print discovered title IDs for --all and exit", +) +@click.option( + "--browser-fallback/--no-browser-fallback", + default=True, + show_default=True, + help="Use Playwright-rendered scraping when static page fetch yields no title IDs", +) +@click.option( + "--raw", + "-r", + is_flag=True, + default=False, + show_default=True, + help="Save raw images", + envvar="MLOADER_RAW", +) +@click.option( + "--format", + "-f", + "output_format", + type=click.Choice(["cbz", "pdf"], case_sensitive=False), + default="cbz", + show_default=True, + help="Save as CBZ or PDF", + envvar="MLOADER_OUTPUT_FORMAT", +) +@click.option( + "--filename-style", + type=click.Choice(["legacy", "new"], case_sensitive=False), + default="legacy", + show_default=True, + help="Filename style for chapter-level outputs (legacy excludes language tags)", + envvar="MLOADER_FILENAME_STYLE", +) +@click.option( + "--rename-existing-filenames", + is_flag=True, + default=False, + show_default=True, + help="Rename existing legacy chapter filenames to the selected filename style", +) +@click.option( + "--capture-api", + "capture_api_dir", + type=click.Path(file_okay=False, writable=True), + metavar="", + help="Dump raw API payload captures (protobuf + metadata) to this directory", + envvar="MLOADER_CAPTURE_API_DIR", +) +@click.option( + "--run-report", + "run_report_path", + type=click.Path(dir_okay=False, writable=True), + metavar="", + help="Write a JSON run report for unattended cron/systemd runs", + envvar="MLOADER_RUN_REPORT_PATH", +) +@click.option( + "--quality", + "-q", + default="super_high", + type=click.Choice(["super_high", "high", "low"]), + show_default=True, + help="Image quality", + envvar="MLOADER_QUALITY", +) +@click.option( + "--split", + "-s", + is_flag=True, + default=False, + show_default=True, + help="Split combined images", + envvar="MLOADER_SPLIT", +) +@click.option( + "--chapter", + "-c", + type=click.INT, + multiple=True, + help="Chapter number (integer, e.g. 1, 12)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--chapter-id", + type=click.INT, + multiple=True, + help="Chapter API ID (integer, e.g. 1024959)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--title", + "-t", + type=click.INT, + multiple=True, + help="Title ID (integer, usually 6 digits, e.g. 100312)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--begin", + "-b", + type=click.IntRange(min=0), + default=0, + show_default=True, + help="Minimal chapter to download", +) +@click.option( + "--end", + "-e", + type=click.IntRange(min=1), + help="Maximal chapter to download", +) +@click.option( + "--last", + "-l", + is_flag=True, + default=False, + show_default=True, + help="Download only the last chapter for each title", +) +@click.option( + "--chapter-title", + is_flag=True, + default=False, + show_default=True, + help="Include chapter titles in filenames", +) +@click.option( + "--chapter-subdir", + is_flag=True, + default=False, + show_default=True, + help="Save raw images in subdirectories by chapter", +) +@click.option( + "--meta", + "-m", + is_flag=True, + default=False, + help="Export additional metadata as JSON", +) +@click.option( + "--cover", + is_flag=True, + default=False, + show_default=True, + help="Download each title cover image (PNG by default)", +) +@click.option( + "--cover-format", + type=click.Choice(COVER_FORMATS, case_sensitive=False), + default="png", + show_default=True, + help="Cover image format; implies --cover when provided", +) +@click.option( + "--resume/--no-resume", + default=True, + show_default=True, + help="Use per-title manifest state to skip already completed chapters", +) +@click.option( + "--manifest-reset", + is_flag=True, + default=False, + show_default=True, + help="Reset per-title manifest state before downloading", +) +@click.argument("urls", nargs=-1, callback=validate_urls, expose_value=False) +@click.pass_context +def main( + ctx: click.Context, + json_output: bool, + quiet: bool, + show_examples: bool, + verbose: int, + out_dir: str, + verify_capture_schema_dir: str | None, + verify_capture_baseline_dir: str | None, + download_all_titles: bool, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + list_only: bool, + browser_fallback: bool, + raw: bool, + output_format: str, + capture_api_dir: str | None, + run_report_path: str | None, + quality: str, + split: bool, + begin: int, + end: int | None, + last: bool, + chapter_title: bool, + chapter_subdir: bool, + filename_style: FilenameStyle, + rename_existing_filenames: bool, + meta: bool, + cover: bool, + cover_format: str, + resume: bool, + manifest_reset: bool, + chapters: set[int] | None = None, + chapter_ids: set[int] | None = None, + titles: set[int] | None = None, +) -> None: + """Run the CLI command and start the configured download flow.""" + setup_logging(level=resolve_log_level(quiet=quiet, verbose=verbose, json_output=json_output)) + presenter = CliPresenter(json_output=json_output, quiet=quiet) + presenter.emit_intro(about.__intro__) + if AUTH_SETTINGS.os.lower() not in SUPPORTED_AUTH_OS_VALUES: + fail( + "Warning: Unsupported API auth OS value configured via environment/config: " + f"'{AUTH_SETTINGS.os}'. Supported values are: ios, android.", + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + if show_examples: + examples = cli_examples.build_cli_examples(prog_name=ctx.info_name or about.__title__) + presenter.emit_examples(examples) + return + + if verify_capture_baseline_dir and not verify_capture_schema_dir: + fail( + "--verify-capture-baseline requires --verify-capture-schema.", + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + if verify_capture_schema_dir: + capture_summary = run_capture_verification_mode( + verify_capture_schema_dir=verify_capture_schema_dir, + verify_capture_baseline_dir=verify_capture_baseline_dir, + presenter=presenter, + ) + presenter.emit_capture_verification( + summary=capture_summary, + capture_dir=verify_capture_schema_dir, + baseline_dir=verify_capture_baseline_dir, + ) + return + + discovery_flag_error = command_requests.validate_discovery_flags( + download_all_titles=download_all_titles, + list_only=list_only, + languages=languages, + ) + if discovery_flag_error is not None: + fail( + discovery_flag_error, + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + request = command_requests.build_download_request( + ctx, + out_dir=out_dir, + raw=raw, + output_format=output_format, + capture_api_dir=capture_api_dir, + quality=quality, + split=split, + begin=begin, + end=end, + last=last, + chapter_title=chapter_title, + chapter_subdir=chapter_subdir, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + meta=meta, + cover=cover, + cover_format=cover_format, + resume=resume, + manifest_reset=manifest_reset, + chapters=chapters, + chapter_ids=chapter_ids, + titles=titles, + run_report_path=run_report_path, + ) + + discovery_metadata: dict[str, int] | None = None + if download_all_titles: + all_mode_request, discovery_metadata = resolve_all_mode_targets( + request=request, + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + list_only=list_only, + presenter=presenter, + ) + if all_mode_request is None: + return + request = all_mode_request + + if not request.has_targets: + click.echo(ctx.get_help()) + raise click.exceptions.Exit(SUCCESS) + + run_download_request( + request, + presenter=presenter, + discovery_metadata=discovery_metadata, + loader_factory=MangaLoader, + raw_exporter=RawExporter, + pdf_exporter=PDFExporter, + cbz_exporter=CBZExporter, + write_run_report=write_run_report_if_requested, + ) + + +if __name__ == "__main__": # pragma: no cover + main(prog_name=about.__title__) diff --git a/mloader/cli/presenter.py b/mloader/cli/presenter.py new file mode 100644 index 0000000..6060324 --- /dev/null +++ b/mloader/cli/presenter.py @@ -0,0 +1,137 @@ +"""CLI presentation helpers for human and JSON output modes.""" + +from __future__ import annotations + +import json +from typing import Any, Iterable, Mapping, Sequence + +import click + +from mloader.application import discovery as discovery_use_cases +from mloader.cli.examples import CliExample +from mloader.domain.requests import DownloadSummary +from mloader.infrastructure.mangaplus.capture_verify import CaptureVerificationSummary + + +class CliPresenter: + """Render command outputs for human and machine-readable modes.""" + + def __init__(self, *, json_output: bool, quiet: bool) -> None: + """Store output-mode flags for rendering decisions.""" + self.json_output = json_output + self.quiet = quiet + + @property + def emits_human_output(self) -> bool: + """Return whether human-readable output should be emitted.""" + return not self.json_output and not self.quiet + + def emit_intro(self, intro: str) -> None: + """Emit a styled intro banner when human output is enabled.""" + if self.emits_human_output: + click.echo(click.style(intro, fg="blue")) + + def emit_notice(self, message: str) -> None: + """Emit one human-readable informational message.""" + if self.emits_human_output: + click.echo(message) + + def emit_notices(self, messages: Iterable[str]) -> None: + """Emit multiple human-readable informational messages.""" + for message in messages: + self.emit_notice(message) + + def emit_capture_verification( + self, + *, + summary: CaptureVerificationSummary, + capture_dir: str, + baseline_dir: str | None, + ) -> None: + """Emit capture-verification output in current render mode.""" + if self.json_output: + self.emit_json( + { + "status": "ok", + "mode": "verify_capture", + "exit_code": 0, + "capture_dir": capture_dir, + "baseline_dir": baseline_dir, + "total_records": summary.total_records, + "endpoint_counts": dict(sorted(summary.endpoint_counts.items())), + } + ) + return + + if not self.emits_human_output: + return + + endpoint_overview = ", ".join( + f"{name}={count}" for name, count in sorted(summary.endpoint_counts.items()) + ) + if baseline_dir: + click.echo( + f"Verified {summary.total_records} capture payload(s) in {capture_dir} " + f"against baseline {baseline_dir} ({endpoint_overview})" + ) + else: + click.echo( + f"Verified {summary.total_records} capture payload(s) in " + f"{capture_dir} ({endpoint_overview})" + ) + + def emit_discovery_summary(self, title_ids: list[int]) -> None: + """Emit discovered title count in human mode.""" + self.emit_notice(discovery_use_cases.summarize_discovery(title_ids)) + + def emit_discovery_ids(self, title_ids: list[int]) -> None: + """Emit discovered title IDs in human mode.""" + self.emit_notice(discovery_use_cases.format_discovered_ids(title_ids)) + + def emit_download_summary(self, summary: DownloadSummary) -> None: + """Emit human-readable download result counters.""" + if not self.emits_human_output: + return + click.echo( + "Download summary: " + f"downloaded={summary.downloaded}, " + f"skipped_manifest={summary.skipped_manifest}, " + f"failed={summary.failed}" + ) + if summary.failed_chapter_ids: + failed_ids = " ".join(str(chapter_id) for chapter_id in summary.failed_chapter_ids) + click.echo(f"Failed chapter IDs: {failed_ids}") + + def emit_examples(self, examples: Sequence[CliExample]) -> None: + """Emit CLI example catalog for human and JSON output modes.""" + if self.json_output: + self.emit_json( + { + "status": "ok", + "mode": "show_examples", + "exit_code": 0, + "count": len(examples), + "examples": [ + { + "title": example.title, + "command": example.command, + "description": example.description, + } + for example in examples + ], + } + ) + return + + click.echo("mloader example catalog") + click.echo("These examples are intentionally exhaustive and option-complete.") + click.echo() + for index, example in enumerate(examples, 1): + click.echo(f"{index}. {example.title}") + click.echo(f" $ {example.command}") + click.echo(f" {example.description}") + click.echo() + + def emit_json(self, payload: Mapping[str, Any]) -> None: + """Emit one machine-readable JSON object to stdout.""" + click.echo(json.dumps(payload, sort_keys=True)) diff --git a/mloader/cli/readme_reference.py b/mloader/cli/readme_reference.py new file mode 100644 index 0000000..54e8b7e --- /dev/null +++ b/mloader/cli/readme_reference.py @@ -0,0 +1,118 @@ +"""Helpers to generate and sync README CLI parameter reference content.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import click + +README_CLI_REFERENCE_START = "" +README_CLI_REFERENCE_END = "" + + +@dataclass(frozen=True, slots=True) +class CliOptionDoc: + """Render-ready documentation row for one CLI option.""" + + names: str + description: str + default: str + envvar: str + + +def _normalize_whitespace(text: str) -> str: + """Collapse multiline help/default strings into single-spaced text.""" + return " ".join(text.split()) + + +def _escape_markdown_cell(text: str) -> str: + """Escape markdown table metacharacters used inside one cell.""" + return text.replace("|", r"\|") + + +def _format_default(option: click.Option) -> str: + """Return normalized default display text for one option.""" + if option.show_default is False: + return "-" + default = option.default + if str(default).startswith("Sentinel."): + return "-" + if default in (None, (), []): + return "-" + if isinstance(default, tuple): + rendered = ", ".join(str(value) for value in default) + return rendered or "-" + if isinstance(default, bool): + return "true" if default else "false" + return str(default) + + +def _format_envvar(option: click.Option) -> str: + """Return normalized envvar display text for one option.""" + envvar = option.envvar + if envvar is None: + return "-" + if isinstance(envvar, tuple): + rendered = ", ".join(str(value) for value in envvar) + return rendered or "-" + return str(envvar) + + +def _format_option_names(option: click.Option) -> str: + """Return display string for primary and secondary option names.""" + names = [*option.opts, *option.secondary_opts] + return ", ".join(f"`{name}`" for name in names) + + +def _iter_option_docs(command: click.Command) -> list[CliOptionDoc]: + """Build docs rows for all click options in command declaration order.""" + rows: list[CliOptionDoc] = [] + for parameter in command.params: + if not isinstance(parameter, click.Option): + continue + rows.append( + CliOptionDoc( + names=_format_option_names(parameter), + description=_escape_markdown_cell(_normalize_whitespace(parameter.help or "")), + default=_escape_markdown_cell(_normalize_whitespace(_format_default(parameter))), + envvar=_escape_markdown_cell(_normalize_whitespace(_format_envvar(parameter))), + ) + ) + return rows + + +def render_cli_parameter_reference(command: click.Command) -> str: + """Render deterministic markdown table for all CLI parameters.""" + rows = _iter_option_docs(command) + lines = [ + "`URLS`:", + "- Positional MangaPlus URLs (`viewer/` and `titles/`).", + "", + "| Option | Description | Default | Env |", + "| --- | --- | --- | --- |", + ] + lines.extend( + f"| {row.names} | {row.description or '-'} | `{row.default}` | `{row.envvar}` |" + for row in rows + ) + return "\n".join(lines) + + +def replace_readme_cli_reference(readme_text: str, *, command: click.Command) -> str: + """Replace README auto-generated CLI reference section with rendered markdown.""" + rendered_reference = render_cli_parameter_reference(command).rstrip() + generated_block = ( + f"{README_CLI_REFERENCE_START}\n{rendered_reference}\n{README_CLI_REFERENCE_END}" + ) + + start_index = readme_text.find(README_CLI_REFERENCE_START) + end_index = readme_text.find(README_CLI_REFERENCE_END) + if start_index == -1 or end_index == -1 or end_index < start_index: + msg = ( + "README CLI reference markers not found. " + f"Expected markers: {README_CLI_REFERENCE_START} / {README_CLI_REFERENCE_END}" + ) + raise ValueError(msg) + + end_offset = end_index + len(README_CLI_REFERENCE_END) + return f"{readme_text[:start_index]}{generated_block}{readme_text[end_offset:]}" diff --git a/mloader/cli/run_report.py b/mloader/cli/run_report.py new file mode 100644 index 0000000..bdea949 --- /dev/null +++ b/mloader/cli/run_report.py @@ -0,0 +1,105 @@ +"""Run-report helpers for CLI command execution.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from mloader.application import downloads as download_use_cases +from mloader.domain.requests import DownloadRequest, DownloadSummary + +log = logging.getLogger(__name__) + +Clock = Callable[[], datetime] +RunIdFactory = Callable[[], str] + + +def utc_now() -> datetime: + """Return the current UTC timestamp for report metadata.""" + return datetime.now(timezone.utc) + + +def new_run_id() -> str: + """Return a new opaque run identifier.""" + return uuid4().hex + + +def summary_payload(summary: DownloadSummary) -> dict[str, object]: + """Build JSON-serializable summary payload from immutable summary model.""" + return { + "downloaded": summary.downloaded, + "skipped_manifest": summary.skipped_manifest, + "failed": summary.failed, + "failed_chapter_ids": list(summary.failed_chapter_ids), + } + + +def write_run_report_if_requested( + request: DownloadRequest, + *, + run_id: str, + started_at: datetime, + status: str, + exit_code: int, + discovery: dict[str, int] | None, + summary: DownloadSummary | None, + error_message: str | None, + subscription_access_failures: int = 0, + completed_at: datetime | None = None, + clock: Clock = utc_now, + path_type: type[Path] = Path, +) -> None: + """Write an optional JSON run report without changing CLI success semantics.""" + if not request.run_report_path: + return + + resolved_completed_at = completed_at or clock() + resolved_summary_payload = ( + summary_payload(summary) + if summary is not None + else { + "downloaded": 0, + "skipped_manifest": 0, + "failed": 0, + "failed_chapter_ids": [], + } + ) + report: dict[str, object] = { + "run_id": run_id, + "status": status, + "exit_code": exit_code, + "started_at_utc": started_at.isoformat(), + "completed_at_utc": resolved_completed_at.isoformat(), + "duration_seconds": round((resolved_completed_at - started_at).total_seconds(), 3), + "selected_args": { + **download_use_cases.to_chapter_id_debug_map(request), + "out_dir": request.out_dir, + "quality": request.quality, + "split": request.split, + }, + "discovery": { + "discovered_title_count": (discovery or {}).get("discovered_titles", 0), + }, + "summary": resolved_summary_payload, + "subscription_access_failures": subscription_access_failures, + "exporter_safety": { + "mode": "disk-backed-tempfiles", + "version": "pdf-streaming-and-atomic-cbz-v1", + }, + } + if error_message: + report["error"] = error_message + + report_path = path_type(request.run_report_path) + try: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + except OSError: + log.warning("Failed to write run report: %s", report_path, exc_info=True) diff --git a/mloader/cli/runtime_options.py b/mloader/cli/runtime_options.py new file mode 100644 index 0000000..0fcb895 --- /dev/null +++ b/mloader/cli/runtime_options.py @@ -0,0 +1,18 @@ +"""CLI runtime option helpers.""" + +from __future__ import annotations + +import logging + +SUPPORTED_AUTH_OS_VALUES: frozenset[str] = frozenset({"ios", "android"}) + + +def resolve_log_level(*, quiet: bool, verbose: int, json_output: bool) -> int: + """Resolve runtime logging level from output and verbosity flags.""" + if quiet: + return logging.WARNING + if verbose >= 1: + return logging.DEBUG + if json_output: + return logging.WARNING + return logging.INFO diff --git a/mloader/cli/validators.py b/mloader/cli/validators.py new file mode 100644 index 0000000..facb797 --- /dev/null +++ b/mloader/cli/validators.py @@ -0,0 +1,69 @@ +"""Click callback validators used by CLI options and arguments.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import click + +ALLOWED_HOSTS = { + "mangaplus.shueisha.co.jp", + "www.mangaplus.shueisha.co.jp", +} + + +def _parse_target(url: str) -> tuple[str, int]: + """Parse and return ``(target_kind, id)`` from supported URL/path formats.""" + is_absolute_url = "://" in url + parsed = urlparse(url if is_absolute_url else f"https://placeholder/{url.lstrip('/')}") + if is_absolute_url and parsed.hostname not in ALLOWED_HOSTS: + raise click.BadParameter(f"Invalid url host: {url}") + segments = [segment for segment in parsed.path.split("/") if segment] + if len(segments) != 2 or not segments[1].isdigit(): + raise click.BadParameter(f"Invalid url: {url}") + if segments[0] not in {"viewer", "titles"}: + raise click.BadParameter(f"Invalid url: {url}") + return segments[0], int(segments[1]) + + +def validate_urls( + ctx: click.Context, + param: click.Parameter | None, + value: tuple[str, ...], +) -> tuple[str, ...]: + """Validate URL arguments and collect chapter/title IDs into click context.""" + _ = param + if not value: + return value + + results: dict[str, set[int]] = {"viewer": set(), "titles": set()} + for url in value: + key, id_value = _parse_target(url) + results[key].add(id_value) + + ctx.params.setdefault("titles", set()).update(results["titles"]) + ctx.params.setdefault("chapter_ids", set()).update(results["viewer"]) + return value + + +def validate_ids( + ctx: click.Context, + param: click.Parameter | None, + value: tuple[int, ...], +) -> tuple[int, ...]: + """Validate numeric IDs and add them to the matching context set.""" + if not value: + return value + if param is None: + raise click.BadParameter("Unexpected missing parameter metadata") + + if param.name == "chapter": + ctx.params.setdefault("chapters", set()).update(value) + return value + if param.name == "chapter_id": + ctx.params.setdefault("chapter_ids", set()).update(value) + return value + if param.name == "title": + ctx.params.setdefault("titles", set()).update(value) + return value + raise click.BadParameter(f"Unexpected parameter: {param.name}") diff --git a/mloader/config.py b/mloader/config.py new file mode 100644 index 0000000..00aa97d --- /dev/null +++ b/mloader/config.py @@ -0,0 +1,127 @@ +"""Typed immutable runtime settings with layered configuration resolution.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import tomllib +from typing import Mapping + +from dotenv import load_dotenv +from mloader.infrastructure.mangaplus.settings import MOBILE_API_HEADERS as _MOBILE_API_HEADERS + +load_dotenv() + +_DEFAULT_AUTH_SETTINGS: dict[str, str] = { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "f40080bcb01a9a963912f46688d411a3", +} +_ENV_TO_FIELD_MAP: dict[str, str] = { + "APP_VER": "app_ver", + "OS": "os", + "OS_VER": "os_ver", + "SECRET": "secret", +} +_DEFAULT_CONFIG_FILE = ".mloader.toml" + + +@dataclass(frozen=True, slots=True) +class AuthSettings: + """Immutable auth-related settings required for MangaPlus API requests.""" + + app_ver: str + os: str + os_ver: str + secret: str + + def as_query_params(self) -> dict[str, str]: + """Return settings as API query parameter mapping.""" + return { + "app_ver": self.app_ver, + "os": self.os, + "os_ver": self.os_ver, + "secret": self.secret, + } + + +def _load_auth_from_file(config_file: str | Path | None) -> dict[str, str]: + """Load auth settings from TOML file, returning empty mapping when unavailable.""" + if config_file is None: + return {} + + config_path = Path(config_file) + if not config_path.exists() or not config_path.is_file(): + return {} + + parsed_config = tomllib.loads(config_path.read_text(encoding="utf-8")) + auth_section = parsed_config.get("auth") + if auth_section is None: + return {} + if not isinstance(auth_section, dict): + raise ValueError("Invalid config file: [auth] section must be a table") + + resolved: dict[str, str] = {} + for key in _DEFAULT_AUTH_SETTINGS: + value = auth_section.get(key) + if value is None: + continue + resolved[key] = str(value) + return resolved + + +def _resolve_config_file( + environ: Mapping[str, str], config_file: str | Path | None +) -> str | Path | None: + """Resolve config file location from explicit argument, env var, or default path.""" + if config_file is not None: + return config_file + + env_config_file = environ.get("MLOADER_CONFIG_FILE") + if env_config_file: + return env_config_file + + default_path = Path(_DEFAULT_CONFIG_FILE) + if default_path.exists() and default_path.is_file(): + return default_path + return None + + +def load_auth_settings( + *, + overrides: Mapping[str, str] | None = None, + environ: Mapping[str, str] | None = None, + config_file: str | Path | None = None, +) -> AuthSettings: + """Load immutable auth settings with layered priority resolution. + + Resolution order (highest to lowest): + 1. ``overrides`` (CLI/runtime injected values) + 2. environment variables + 3. TOML config file (explicit path, env-selected path, or default) + 4. built-in defaults + """ + resolved_environ = environ if environ is not None else os.environ + + merged: dict[str, str] = dict(_DEFAULT_AUTH_SETTINGS) + resolved_config_file = _resolve_config_file(resolved_environ, config_file) + merged.update(_load_auth_from_file(resolved_config_file)) + + for env_key, field_name in _ENV_TO_FIELD_MAP.items(): + env_value = resolved_environ.get(env_key) + if env_value is not None: + merged[field_name] = env_value + + if overrides: + for key, value in overrides.items(): + if key not in _DEFAULT_AUTH_SETTINGS: + raise ValueError(f"Unsupported auth override key: {key}") + merged[key] = str(value) + + return AuthSettings(**merged) + + +AUTH_SETTINGS = load_auth_settings() +MOBILE_API_HEADERS = dict(_MOBILE_API_HEADERS) diff --git a/mloader/constants.py b/mloader/constants.py index 2ef3f9e..b01b2bb 100644 --- a/mloader/constants.py +++ b/mloader/constants.py @@ -1,26 +1,34 @@ -from enum import Enum - - -class Language(Enum): - eng = 0 - spa = 1 - fre = 2 - ind = 3 - por = 4 - rus = 5 - tha = 6 - deu = 7 - vie = 9 - - -class ChapterType(Enum): - latest = 0 - sequence = 1 - nosequence = 2 - - -class PageType(Enum): - single = 0 - left = 1 - right = 2 - double = 3 +"""Domain enums used across loader and exporters.""" + +from enum import Enum + + +class Language(Enum): + """Represent supported manga languages.""" + + ENGLISH = 0 + SPANISH = 1 + FRENCH = 2 + INDONESIAN = 3 + PORTUGUESE = 4 + RUSSIAN = 5 + THAI = 6 + GERMAN = 7 + VIETNAMESE = 9 + + +class ChapterType(Enum): + """Represent chapter ordering categories returned by the API.""" + + LATEST = 0 + SEQUENCE = 1 + NO_SEQUENCE = 2 + + +class PageType(Enum): + """Represent page layout types in manga viewer responses.""" + + SINGLE = 0 + LEFT = 1 + RIGHT = 2 + DOUBLE = 3 diff --git a/mloader/domain/__init__.py b/mloader/domain/__init__.py new file mode 100644 index 0000000..cf83f39 --- /dev/null +++ b/mloader/domain/__init__.py @@ -0,0 +1 @@ +"""Domain models for immutable CLI/application inputs.""" diff --git a/mloader/domain/manga.py b/mloader/domain/manga.py new file mode 100644 index 0000000..08ed21d --- /dev/null +++ b/mloader/domain/manga.py @@ -0,0 +1,137 @@ +"""Stable domain models for MangaPlus title and chapter payloads.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Chapter: + """Domain representation of one MangaPlus chapter.""" + + title_id: int + chapter_id: int + name: str + sub_title: str + thumbnail_url: str + start_timestamp: int = 0 + end_timestamp: int = 0 + already_viewed: bool = False + is_vertical_only: bool = False + + +@dataclass(frozen=True, slots=True) +class TitleTag: + """Domain representation of one MangaPlus title tag.""" + + name: str + slug: str + + +@dataclass(frozen=True, slots=True) +class Title: + """Domain representation of one MangaPlus title.""" + + title_id: int + name: str + author: str + portrait_image_url: str + landscape_image_url: str + language: int + overview: str = "" + tags: tuple[TitleTag, ...] = () + web_url: str = "" + + +@dataclass(frozen=True, slots=True) +class ChapterGroup: + """Domain representation of grouped title-detail chapter lists.""" + + first_chapters: tuple[Chapter, ...] + mid_chapters: tuple[Chapter, ...] + last_chapters: tuple[Chapter, ...] + + @property + def chapters(self) -> tuple[Chapter, ...]: + """Return all chapters in MangaPlus display order.""" + return (*self.first_chapters, *self.mid_chapters, *self.last_chapters) + + +@dataclass(frozen=True, slots=True) +class TitleDetail: + """Domain representation of a title-detail response.""" + + title: Title + title_image_url: str + overview: str + non_appearance_info: str + number_of_views: int + chapter_groups: tuple[ChapterGroup, ...] + + @property + def chapters(self) -> tuple[Chapter, ...]: + """Return all chapters from all groups in MangaPlus display order.""" + return tuple(chapter for group in self.chapter_groups for chapter in group.chapters) + + def find_chapter(self, chapter_id: int) -> Chapter | None: + """Return the chapter matching ``chapter_id``, if it exists in this title.""" + return next( + (chapter for chapter in self.chapters if chapter.chapter_id == chapter_id), + None, + ) + + +@dataclass(frozen=True, slots=True) +class MangaPage: + """Domain representation of one downloadable page image.""" + + image_url: str + width: int + height: int + page_type: int + encryption_key: str + + +@dataclass(frozen=True, slots=True) +class LastPage: + """Domain representation of terminal viewer-page metadata.""" + + current_chapter: Chapter + next_chapter: Chapter | None + + +@dataclass(frozen=True, slots=True) +class ViewerPage: + """Domain representation of one viewer page envelope.""" + + manga_page: MangaPage | None + last_page: LastPage | None + + +@dataclass(frozen=True, slots=True) +class MangaViewer: + """Domain representation of a chapter viewer response.""" + + title_id: int + chapter_id: int + title_name: str + chapter_name: str + chapters: tuple[Chapter, ...] + pages: tuple[ViewerPage, ...] + + @property + def last_page(self) -> LastPage | None: + """Return terminal viewer-page metadata when present.""" + return next( + (page.last_page for page in reversed(self.pages) if page.last_page is not None), + None, + ) + + @property + def downloadable_pages(self) -> tuple[MangaPage, ...]: + """Return only pages that include downloadable image URLs.""" + return tuple( + page.manga_page + for page in self.pages + if page.manga_page is not None and page.manga_page.image_url + ) diff --git a/mloader/domain/planning.py b/mloader/domain/planning.py new file mode 100644 index 0000000..bedf999 --- /dev/null +++ b/mloader/domain/planning.py @@ -0,0 +1,267 @@ +"""Domain services and models for normalized download planning.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Callable, Collection + +from mloader.domain.manga import Chapter, ChapterGroup, MangaViewer, TitleDetail +from mloader.utils import chapter_name_to_int + + +@dataclass(frozen=True, slots=True) +class ChapterSelection: + """Selected chapter IDs for one title.""" + + title_id: int + chapter_ids: frozenset[int] + + @property + def chapter_count(self) -> int: + """Return the number of selected chapters.""" + return len(self.chapter_ids) + + +@dataclass(frozen=True, slots=True) +class TitleDownloadPlan: + """Concrete title details and chapters selected for download.""" + + title_detail: TitleDetail + selected_chapters: tuple[Chapter, ...] + + @property + def title_id(self) -> int: + """Return the selected title ID.""" + return self.title_detail.title.title_id + + @property + def chapter_ids(self) -> frozenset[int]: + """Return selected chapter IDs for this title.""" + return frozenset(chapter.chapter_id for chapter in self.selected_chapters) + + @property + def chapter_count(self) -> int: + """Return the number of selected chapters.""" + return len(self.selected_chapters) + + +@dataclass(frozen=True, slots=True) +class DownloadPlan: + """Concrete title/chapter plan for one download run.""" + + title_plans: tuple[TitleDownloadPlan, ...] + + @property + def selections(self) -> tuple[ChapterSelection, ...]: + """Return ID-only selections derived from concrete title plans.""" + return tuple( + ChapterSelection(title_id=title_plan.title_id, chapter_ids=title_plan.chapter_ids) + for title_plan in self.title_plans + ) + + @property + def title_count(self) -> int: + """Return the number of selected titles.""" + return len(self.title_plans) + + @property + def chapter_count(self) -> int: + """Return the total number of selected chapters.""" + return sum(title_plan.chapter_count for title_plan in self.title_plans) + + +TitleDetailLoader = Callable[[int], TitleDetail] +MangaViewerLoader = Callable[[int], MangaViewer] + + +def build_download_plan( + *, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + load_title_detail: TitleDetailLoader, + load_viewer: MangaViewerLoader, +) -> DownloadPlan: + """Resolve validated target filters into a concrete domain download plan.""" + selected_ids_by_title, fallback_chapters, loaded_title_details = _resolve_planning_inputs( + title_ids=title_ids, + chapter_numbers=chapter_numbers, + chapter_ids=chapter_ids, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + load_title_detail=load_title_detail, + load_viewer=load_viewer, + ) + + title_plans: list[TitleDownloadPlan] = [] + for title_id, selected_chapter_ids in sorted(selected_ids_by_title.items()): + title_detail = loaded_title_details.get(title_id) or load_title_detail(title_id) + chapters_by_id = {chapter.chapter_id: chapter for chapter in title_detail.chapters} + selected_chapters = [ + chapter + for chapter in title_detail.chapters + if chapter.chapter_id in selected_chapter_ids + ] + selected_chapter_id_set = {chapter.chapter_id for chapter in selected_chapters} + missing_chapter_ids = selected_chapter_ids - selected_chapter_id_set + selected_chapters.extend( + fallback_chapters[chapter_id] + for chapter_id in sorted(missing_chapter_ids) + if chapter_id in fallback_chapters and chapter_id not in chapters_by_id + ) + title_plans.append( + TitleDownloadPlan( + title_detail=title_detail, + selected_chapters=tuple(selected_chapters), + ) + ) + + return DownloadPlan(title_plans=tuple(title_plans)) + + +def title_detail_with_selected_chapters( + title_detail: TitleDetail, + selected_chapters: Collection[Chapter], +) -> TitleDetail: + """Return title details augmented with direct-ID selected chapters if needed.""" + missing_chapters = tuple( + chapter + for chapter in selected_chapters + if title_detail.find_chapter(chapter.chapter_id) is None + ) + if not missing_chapters: + return title_detail + return replace( + title_detail, + chapter_groups=( + *title_detail.chapter_groups, + ChapterGroup( + first_chapters=missing_chapters, + mid_chapters=(), + last_chapters=(), + ), + ), + ) + + +def _resolve_planning_inputs( + *, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + load_title_detail: TitleDetailLoader, + load_viewer: MangaViewerLoader, +) -> tuple[dict[int, set[int]], dict[int, Chapter], dict[int, TitleDetail]]: + """Resolve filters while carrying title details loaded during planning.""" + if not any((title_ids, chapter_numbers, chapter_ids)): + raise ValueError("Expected at least one title or chapter id") + + remaining_title_ids = set(title_ids or []) + provided_chapter_numbers = set(chapter_numbers or []) + provided_chapter_ids = set(chapter_ids or []) + direct_chapter_ids_by_title: dict[int, set[int]] = {} + candidate_chapters_by_title: dict[int, list[Chapter]] = {} + fallback_chapters_by_id: dict[int, Chapter] = {} + loaded_title_details: dict[int, TitleDetail] = {} + + if provided_chapter_numbers and not remaining_title_ids and not provided_chapter_ids: + raise ValueError("Chapter numbers require at least one title ID, --all, or viewer URL.") + + for chapter_id in provided_chapter_ids: + viewer = load_viewer(chapter_id) + current_title_id = viewer.title_id + direct_chapter_ids_by_title.setdefault(current_title_id, set()).add(viewer.chapter_id) + viewer_chapters = _viewer_chapters_for_planning(viewer) + for chapter in viewer_chapters: + fallback_chapters_by_id[chapter.chapter_id] = chapter + + if current_title_id in remaining_title_ids: + remaining_title_ids.remove(current_title_id) + candidate_chapters_by_title.setdefault(current_title_id, []).extend(viewer_chapters) + else: + candidate_chapters_by_title.setdefault(current_title_id, []).append( + _viewer_current_chapter(viewer) + ) + + titles_requiring_full_details = set(remaining_title_ids) + if provided_chapter_numbers: + titles_requiring_full_details.update(candidate_chapters_by_title) + + for title_id in titles_requiring_full_details: + title_detail = load_title_detail(title_id) + loaded_title_details[title_id] = title_detail + candidate_chapters_by_title[title_id] = list(title_detail.chapters) + + selected_ids_by_title: dict[int, set[int]] = {} + for title_id, candidate_chapters in candidate_chapters_by_title.items(): + selected_chapters = _select_candidate_chapters( + candidate_chapters, + chapter_numbers=provided_chapter_numbers, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + ) + selected_ids = {chapter.chapter_id for chapter in selected_chapters} + selected_ids.update(direct_chapter_ids_by_title.get(title_id, set())) + selected_ids_by_title[title_id] = selected_ids + + return selected_ids_by_title, fallback_chapters_by_id, loaded_title_details + + +def _select_candidate_chapters( + chapters: Collection[Chapter], + *, + chapter_numbers: Collection[int], + min_chapter: int, + max_chapter: int, + last_chapter: bool, +) -> list[Chapter]: + """Apply chapter-number/range selection to candidate chapters.""" + chapter_list = list(chapters) + if last_chapter: + return chapter_list[-1:] + + if chapter_numbers: + return [ + chapter + for chapter in chapter_list + if (chapter_number := chapter_name_to_int(chapter.name)) is not None + and chapter_number in chapter_numbers + and min_chapter <= chapter_number <= max_chapter + ] + + return [ + chapter + for chapter in chapter_list + if min_chapter <= (chapter_name_to_int(chapter.name) or 0) <= max_chapter + ] + + +def _viewer_chapters_for_planning(viewer: MangaViewer) -> tuple[Chapter, ...]: + """Return viewer chapter list or a fallback current chapter.""" + if viewer.chapters: + return viewer.chapters + return (_viewer_current_chapter(viewer),) + + +def _viewer_current_chapter(viewer: MangaViewer) -> Chapter: + """Return the viewer's current chapter, synthesizing a minimal fallback if needed.""" + if viewer.last_page is not None: + return viewer.last_page.current_chapter + for chapter in viewer.chapters: + if chapter.chapter_id == viewer.chapter_id: + return chapter + return Chapter( + title_id=viewer.title_id, + chapter_id=viewer.chapter_id, + name=viewer.chapter_name, + sub_title="", + thumbnail_url="", + ) diff --git a/mloader/domain/requests.py b/mloader/domain/requests.py new file mode 100644 index 0000000..0cb6d14 --- /dev/null +++ b/mloader/domain/requests.py @@ -0,0 +1,83 @@ +"""Immutable request models shared between CLI and application layers.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Literal + +ApiOutputFormat = Literal["cbz", "pdf"] +EffectiveOutputFormat = Literal["raw", "cbz", "pdf"] +FilenameStyle = Literal["legacy", "new"] +CoverFormat = Literal["png", "jpg", "webp"] +COVER_FORMATS: tuple[CoverFormat, ...] = ("png", "jpg", "webp") +MAX_CHAPTER_ID = 2_147_483_647 + + +@dataclass(frozen=True, slots=True) +class DiscoveryRequest: + """All inputs required to discover title IDs for ``--all`` mode.""" + + pages: tuple[str, ...] + title_index_endpoint: str + id_length: int | None + languages: tuple[str, ...] + browser_fallback: bool + capture_api_dir: str | None = None + + +@dataclass(frozen=True, slots=True) +class DownloadRequest: + """Inputs required to execute one download run.""" + + out_dir: str + raw: bool + output_format: ApiOutputFormat + capture_api_dir: str | None + quality: str + split: bool + begin: int + end: int | None + last: bool + chapter_title: bool + chapter_subdir: bool + meta: bool + cover: bool + cover_format: CoverFormat + resume: bool + manifest_reset: bool + chapters: frozenset[int] + chapter_ids: frozenset[int] + titles: frozenset[int] + filename_style: FilenameStyle = "legacy" + rename_existing_filenames: bool = False + run_report_path: str | None = None + + @property + def max_chapter(self) -> int: + """Return the inclusive upper chapter bound for the run.""" + return self.end if self.end is not None else MAX_CHAPTER_ID + + def with_additional_titles(self, title_ids: set[int] | frozenset[int]) -> DownloadRequest: + """Return a new request with additional title IDs merged in.""" + merged_titles = self.titles.union(title_ids) + return replace(self, titles=frozenset(merged_titles)) + + @property + def has_targets(self) -> bool: + """Return whether at least one title/chapter target is configured.""" + return bool(self.chapters or self.chapter_ids or self.titles) + + +@dataclass(frozen=True, slots=True) +class DownloadSummary: + """Summary counters reported for one completed download run.""" + + downloaded: int + skipped_manifest: int + failed: int + failed_chapter_ids: tuple[int, ...] + + @property + def has_failures(self) -> bool: + """Return whether the run encountered at least one failed chapter.""" + return self.failed > 0 diff --git a/mloader/errors.py b/mloader/errors.py new file mode 100644 index 0000000..0722a91 --- /dev/null +++ b/mloader/errors.py @@ -0,0 +1,65 @@ +"""Domain-specific exceptions raised by mloader runtime components.""" + +from __future__ import annotations + +from typing import Literal + +from mloader.domain.requests import DownloadSummary + +ApiErrorKind = Literal[ + "http", + "network", + "api_error", + "subscription_required", + "empty", + "unknown", +] +FailureKind = Literal[ + "validation", + "external_dependency", + "subscription_required", + "interrupted", + "internal_bug", +] + + +class MLoaderError(Exception): + """Base exception for mloader-specific runtime failures.""" + + error_kind: FailureKind = "internal_bug" + + +class SubscriptionRequiredError(MLoaderError): + """Raised when a chapter requires a subscription unavailable to the caller.""" + + error_kind: FailureKind = "subscription_required" + + +class DownloadInterruptedError(MLoaderError): + """Raised when a download run is interrupted by user signal.""" + + error_kind: FailureKind = "interrupted" + + def __init__(self, summary: DownloadSummary) -> None: + """Store partial run summary for CLI reporting.""" + super().__init__("Download interrupted by user.") + self.summary = summary + + +class APIResponseError(MLoaderError): + """Raised when MangaPlus API returns an invalid or non-success payload.""" + + error_kind: FailureKind = "external_dependency" + + def __init__( + self, + message: str, + *, + kind: ApiErrorKind = "unknown", + code: str | None = None, + ) -> None: + """Store a machine-readable API failure classification.""" + super().__init__(message) + self.kind = kind + self.api_error_kind = kind + self.code = code diff --git a/mloader/exporter.py b/mloader/exporter.py deleted file mode 100644 index bf119a0..0000000 --- a/mloader/exporter.py +++ /dev/null @@ -1,162 +0,0 @@ -import zipfile -from abc import ABCMeta, abstractmethod -from itertools import chain -from pathlib import Path -from typing import Union, Optional - -from mloader.constants import Language -from mloader.response_pb2 import Title, Chapter -from mloader.utils import ( - escape_path, - is_oneshot, - chapter_name_to_int, - is_windows, -) - - -class ExporterBase(metaclass=ABCMeta): - def __init__( - self, - destination: str, - title: Title, - chapter: Chapter, - next_chapter: Optional[Chapter] = None, - add_chapter_title: bool = False, - add_chapter_subdir: bool = False, - ): - self.destination = destination - - if is_windows(): - destination = Path(self.destination).resolve().as_posix() - self.destination = f"\\\\?\\{destination}" - - self.add_chapter_title = add_chapter_title - self.add_chapter_subdir = add_chapter_subdir - self.title_name = escape_path(title.name).title() - self.is_oneshot = is_oneshot(chapter.name, chapter.sub_title) - self.is_extra = self._is_extra(chapter.name) - - self._extra_info = [] - - if self.is_oneshot: - self._extra_info.append("[Oneshot]") - - if self.add_chapter_title: - self._extra_info.append(f"[{escape_path(chapter.sub_title)}]") - - self._chapter_prefix = self._format_chapter_prefix( - self.title_name, - chapter.name, - title.language, - next_chapter and next_chapter.name, - ) - self._chapter_suffix = self._format_chapter_suffix() - self.chapter_name = " ".join( - (self._chapter_prefix, self._chapter_suffix) - ) - - def _is_extra(self, chapter_name: str) -> bool: - return chapter_name.strip("#") == "ex" - - def _format_chapter_prefix( - self, - title_name: str, - chapter_name: str, - language: int, - next_chapter_name: Optional[str] = None, - ) -> str: - # https://github.com/Daiz/manga-naming-scheme - components = [title_name] - if Language(language) != Language.eng: - components.append(f"[{Language(language).name}]") - components.append("-") - suffix = "" - prefix = "" - if self.is_oneshot: - chapter_num = 0 - elif self.is_extra and next_chapter_name: - suffix = "x1" - chapter_num = chapter_name_to_int(next_chapter_name) - if chapter_num is not None: - chapter_num -= 1 - prefix = "c" if chapter_num < 1000 else "d" - else: - chapter_num = chapter_name_to_int(chapter_name) - if chapter_num is not None: - prefix = "c" if chapter_num < 1000 else "d" - - if chapter_num is None: - chapter_num = escape_path(chapter_name) - - components.append(f"{prefix}{chapter_num:0>3}{suffix}") - components.append("(web)") - return " ".join(components) - - def _format_chapter_suffix(self) -> str: - return " ".join(chain(self._extra_info, ["[Unknown]"])) - - def format_page_name(self, page: Union[int, range], ext=".jpg") -> str: - if isinstance(page, range): - page = f"p{page.start:0>3}-{page.stop:0>3}" - else: - page = f"p{page:0>3}" - - ext = ext.lstrip(".") - - return f"{self._chapter_prefix} - {page} {self._chapter_suffix}.{ext}" - - def close(self): - pass - - @abstractmethod - def add_image(self, image_data: bytes, index: Union[int, range]): - pass - - @abstractmethod - def skip_image(self, index: Union[int, range]) -> bool: - pass - - -class RawExporter(ExporterBase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.path = Path(self.destination, self.title_name) - self.path.mkdir(parents=True, exist_ok=True) - if self.add_chapter_subdir: - self.path = self.path.joinpath(self.chapter_name) - self.path.mkdir(parents=True, exist_ok=True) - - def add_image(self, image_data: bytes, index: Union[int, range]): - filename = Path(self.format_page_name(index)) - self.path.joinpath(filename).write_bytes(image_data) - - def skip_image(self, index: Union[int, range]) -> bool: - filename = Path(self.format_page_name(index)) - return self.path.joinpath(filename).exists() - - -class CBZExporter(ExporterBase): - def __init__(self, compression=zipfile.ZIP_DEFLATED, *args, **kwargs): - super().__init__(*args, **kwargs) - self.path = Path(self.destination, self.title_name) - self.path.mkdir(parents=True, exist_ok=True) - self.path = self.path.joinpath(self.chapter_name).with_suffix(".cbz") - self.skip_all_images = self.path.exists() - if not self.skip_all_images: - self.archive = zipfile.ZipFile( - self.path, mode="w", compression=compression - ) - - def add_image(self, image_data: bytes, index: Union[int, range]): - if self.skip_all_images: - return - path = Path(self.chapter_name, self.format_page_name(index)) - self.archive.writestr(path.as_posix(), image_data) - - def skip_image(self, index: Union[int, range]) -> bool: - return self.skip_all_images - - def close(self): - if self.skip_all_images: - return - self.archive.close() diff --git a/mloader/exporters/__init__.py b/mloader/exporters/__init__.py new file mode 100644 index 0000000..04e1a07 --- /dev/null +++ b/mloader/exporters/__init__.py @@ -0,0 +1,13 @@ +"""Public exporter package re-exports.""" + +from mloader.exporters.exporter_base import ExporterBase +from mloader.exporters.cbz_exporter import CBZExporter +from mloader.exporters.pdf_exporter import PDFExporter +from mloader.exporters.raw_exporter import RawExporter + +__all__ = [ + "CBZExporter", + "ExporterBase", + "PDFExporter", + "RawExporter", +] diff --git a/mloader/exporters/cbz_exporter.py b/mloader/exporters/cbz_exporter.py new file mode 100644 index 0000000..e746220 --- /dev/null +++ b/mloader/exporters/cbz_exporter.py @@ -0,0 +1,176 @@ +"""CBZ archive exporter implementation.""" + +from __future__ import annotations + +from contextlib import suppress +from datetime import UTC, datetime +from html import escape +import zipfile +from pathlib import Path +from tempfile import NamedTemporaryFile + +from mloader.exporters.exporter_base import ExporterBase +from mloader.types import ChapterLike, PageIndex, TitleLike + + +class CBZExporter(ExporterBase): + """Export manga pages into a CBZ archive.""" + + format = "cbz" + + def __init__( + self, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + compression: int = zipfile.ZIP_DEFLATED, + ) -> None: + """Initialize archive path and optional in-memory zip buffer.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + add_language_to_chapter_name=add_language_to_chapter_name, + ) + base_path = Path(self.destination, self.title_name) + base_path.mkdir(parents=True, exist_ok=True) + self.path = base_path.joinpath(self.chapter_name).with_suffix(".cbz") + self.summary = str(getattr(title, "overview", "") or "") + self.web_url = str(getattr(title, "web_url", "") or "") + self.tags = tuple(getattr(title, "tags", ()) or ()) + self._page_count = 0 + + self.skip_all_images = self.path.exists() + self._temp_path: Path | None = None + if not self.skip_all_images: + with NamedTemporaryFile( + "wb", + delete=False, + dir=self.path.parent, + prefix=f".{self.path.stem}.", + suffix=".tmp", + ) as tmp: + self._temp_path = Path(tmp.name) + self.archive = zipfile.ZipFile( + self._temp_path, + mode="w", + compression=compression, + ) + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Write one image into the CBZ archive.""" + if self.skip_all_images: + return + self.archive.writestr(self.format_page_name(index), image_data) + self._page_count += 1 + + def skip_image(self, index: PageIndex) -> bool: + """Return whether image writes should be skipped.""" + _ = index + return self.skip_all_images + + def _generate_comicinfo_xml(self) -> str: + """Generate a ComicInfo.xml payload for the current chapter export.""" + lines = [ + '', + "", + self._comicinfo_element("Series", self.series_name or ""), + self._comicinfo_element("Number", str(self.chapter_number or "")), + self._comicinfo_element("Title", self.chapter_title or ""), + self._comicinfo_element("Writer", self.author or ""), + self._comicinfo_element("LanguageISO", self._iso_language()), + self._comicinfo_element("Manga", "YesAndRightToLeft"), + self._comicinfo_element("Publisher", "Shueisha"), + self._comicinfo_element("Format", "Digital"), + ] + lines.extend(self._comicinfo_date_elements()) + lines.extend(self._optional_comicinfo_element("Summary", self.summary)) + lines.extend(self._optional_comicinfo_element("Web", self.web_url)) + lines.extend(self._optional_comicinfo_element("PageCount", str(self._page_count))) + tag_names = self._tag_names() + genre_value = ", ".join(tag_names) if tag_names else "Manga" + lines.append(self._comicinfo_element("Genre", genre_value)) + if tag_names: + lines.append(self._comicinfo_element("Tags", ", ".join(tag_names))) + lines.append("") + return "\n".join(lines) + "\n" + + @staticmethod + def _comicinfo_element(name: str, value: str) -> str: + """Return an escaped ComicInfo element line.""" + return f" <{name}>{escape(value)}" + + def _optional_comicinfo_element(self, name: str, value: str) -> list[str]: + """Return one optional ComicInfo element when ``value`` is non-empty.""" + if not value: + return [] + return [self._comicinfo_element(name, value)] + + def _comicinfo_date_elements(self) -> list[str]: + """Return ComicInfo release-date elements from the chapter timestamp.""" + start_timestamp = int(getattr(self.chapter, "start_timestamp", 0) or 0) + if start_timestamp <= 0: + return [] + release_date = datetime.fromtimestamp(start_timestamp, UTC) + return [ + self._comicinfo_element("Year", str(release_date.year)), + self._comicinfo_element("Month", str(release_date.month)), + self._comicinfo_element("Day", str(release_date.day)), + ] + + def _tag_names(self) -> tuple[str, ...]: + """Return non-empty MangaPlus tag display names in API order.""" + return tuple( + tag_name for tag in self.tags if (tag_name := str(getattr(tag, "name", "") or "")) + ) + + def _write_comicinfo_xml_entry(self) -> None: + """Write ComicInfo.xml at the root of the CBZ archive.""" + xml_path = "ComicInfo.xml" + with suppress(Exception): + if xml_path in self.archive.namelist(): + return + + self.archive.writestr(xml_path, self._generate_comicinfo_xml()) + + def close(self) -> None: + """Atomically persist the disk-backed archive with ComicInfo metadata.""" + if self.skip_all_images: + return + + replaced = False + try: + try: + self._write_comicinfo_xml_entry() + finally: + with suppress(Exception): + self.archive.close() + if self._temp_path is None: + return + self._temp_path.replace(self.path) + replaced = True + self.skip_all_images = True + finally: + if not replaced and self._temp_path is not None: + with suppress(FileNotFoundError): + self._temp_path.unlink() + self._temp_path = None + + def discard(self) -> None: + """Clean a partially written temporary archive without publishing it.""" + if self.skip_all_images: + return + + with suppress(Exception): + self.archive.close() + if self._temp_path is not None: + with suppress(FileNotFoundError): + self._temp_path.unlink() + self._temp_path = None diff --git a/mloader/exporters/exporter_base.py b/mloader/exporters/exporter_base.py new file mode 100644 index 0000000..dbc7017 --- /dev/null +++ b/mloader/exporters/exporter_base.py @@ -0,0 +1,140 @@ +"""Base exporter contract and shared naming logic.""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from pathlib import Path +from typing import ClassVar + +from mloader.constants import Language +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.types import ChapterLike, PageIndex, TitleLike +from mloader.utils import escape_path, is_oneshot, is_windows + + +def _is_extra(chapter_name: str) -> bool: + """Return ``True`` when a chapter name represents an extra chapter.""" + return chapter_name.strip("#").lower() == "ex" + + +def _iso_language_code(language: int) -> str: + """Convert internal language codes to ISO 639-1 values.""" + language_map: dict[int, str] = { + Language.ENGLISH.value: "en", + Language.SPANISH.value: "es", + Language.FRENCH.value: "fr", + Language.INDONESIAN.value: "id", + Language.PORTUGUESE.value: "pt", + Language.RUSSIAN.value: "ru", + Language.THAI.value: "th", + Language.GERMAN.value: "de", + Language.VIETNAMESE.value: "vi", + 8: "vi", # Legacy Vietnamese code observed in historical payloads. + } + return language_map.get(language, "en") + + +class ExporterBase(metaclass=ABCMeta): + """Define the interface and shared behavior for all exporters.""" + + FORMAT_REGISTRY: dict[str, type["ExporterBase"]] = {} + format: ClassVar[str] + + def __init__( + self, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + ) -> None: + """Initialize exporter state and derive chapter naming parts.""" + self.destination = destination + + if is_windows(): + resolved_path = Path(self.destination).resolve().as_posix() + self.destination = f"\\\\?\\{resolved_path}" + + self.add_chapter_title = add_chapter_title + self.add_chapter_subdir = add_chapter_subdir + self.add_language_to_chapter_name = add_language_to_chapter_name + self.series_name = title.name + self.title_name = escape_path(title.name).title() + self.chapter = chapter + self.next_chapter = next_chapter + self.language = title.language + self.author = title.author + self.chapter_title = chapter.sub_title + self.chapter_number = chapter.name + self.is_oneshot = is_oneshot(chapter.name, chapter.sub_title) + self.is_extra = _is_extra(chapter.name) + + self._chapter_prefix = self._format_chapter_prefix( + self.title_name, + chapter.name, + title.language, + ) + self._chapter_suffix = self._format_chapter_suffix() + self.chapter_name = f"{self._chapter_prefix} {self._chapter_suffix}" + + def _format_chapter_prefix( + self, + title_name: str, + chapter_name: str, + language: int, + next_chapter_name: str | None = None, + ) -> str: + """Build the filename prefix used by chapter and page outputs.""" + _ = next_chapter_name + safe_chapter_name = escape_path(chapter_name) + lang = ( + FilenamePolicy.format_language_tag(language) + if self.add_language_to_chapter_name + else "" + ) + return f"{title_name}{lang} - {safe_chapter_name}" + + def _format_chapter_suffix(self) -> str: + """Build the filename suffix based on chapter subtitle.""" + safe_subtitle = ( + escape_path(self.chapter.sub_title) + if self.chapter.sub_title and self.chapter.sub_title.strip() + else "Unknown" + ) + return f"- {safe_subtitle}" + + def _iso_language(self) -> str: + """Return the chapter language as an ISO 639-1 code.""" + return _iso_language_code(self.language) + + def format_page_name(self, page: PageIndex, ext: str = ".jpg") -> str: + """Return the canonical page filename for ``page``.""" + if isinstance(page, range): + page_str = f"p{page.start:0>3}-{page.stop:0>3}" + else: + page_str = f"p{page:0>3}" + return f"{self._chapter_prefix} - {page_str} {self._chapter_suffix}.{ext.lstrip('.')}" + + def close(self) -> None: + """Finalize exporter resources if needed.""" + + def discard(self) -> None: + """Clean temporary resources without publishing an output artifact.""" + + def __init_subclass__(cls, **kwargs: object) -> None: + """Register subclasses by their declared ``format`` key.""" + format_name = getattr(cls, "format", "") + if not isinstance(format_name, str) or not format_name: + raise TypeError("Exporter subclasses must define a non-empty string 'format'.") + cls.FORMAT_REGISTRY[format_name] = cls + super().__init_subclass__(**kwargs) + + @abstractmethod + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Persist a single page image.""" + + @abstractmethod + def skip_image(self, index: PageIndex) -> bool: + """Return whether writing ``index`` can be skipped.""" diff --git a/mloader/exporters/pdf_exporter.py b/mloader/exporters/pdf_exporter.py new file mode 100644 index 0000000..048ebb2 --- /dev/null +++ b/mloader/exporters/pdf_exporter.py @@ -0,0 +1,137 @@ +"""PDF exporter implementation.""" + +from __future__ import annotations + +import img2pdf +from contextlib import suppress +from pathlib import Path +from tempfile import NamedTemporaryFile, TemporaryDirectory + +from PIL import Image + +from mloader.__version__ import __title__, __version__ +from mloader.exporters.exporter_base import ExporterBase +from mloader.types import ChapterLike, PageIndex, TitleLike + + +class PDFExporter(ExporterBase): + """Export manga pages into a PDF file.""" + + format = "pdf" + + def __init__( + self, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + ) -> None: + """Initialize PDF path and disk-backed page buffering state.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + add_language_to_chapter_name=add_language_to_chapter_name, + ) + base_path = Path(self.destination, self.title_name) + base_path.mkdir(parents=True, exist_ok=True) + self.path = base_path.joinpath(self.chapter_name).with_suffix(".pdf") + self.skip_all_images = self.path.exists() + self._temp_dir: TemporaryDirectory[str] | None = None + self._page_paths: list[Path] = [] + if not self.skip_all_images: + self._temp_dir = TemporaryDirectory(prefix="mloader-pdf-", dir=base_path) + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Persist one image payload into a temporary page buffer file.""" + if self.skip_all_images: + return + if self._temp_dir is None: + return + + if isinstance(index, range): + sort_key = index.start + else: + sort_key = index + page_path = Path(self._temp_dir.name) / f"{sort_key:08d}.img" + page_path.write_bytes(image_data) + self._page_paths.append(page_path) + + def skip_image(self, index: PageIndex) -> bool: + """Return whether the chapter PDF already exists.""" + _ = index + return self.skip_all_images + + def _prepare_page_for_pdf(self, page_path: Path, prepared_dir: Path) -> Path: + """Return a PDF-safe image path, converting unsupported modes to RGB JPEG.""" + with Image.open(page_path) as image: + if image.mode in {"1", "L", "RGB", "CMYK"}: + return page_path + + converted = image.convert("RGB") + converted_path = prepared_dir / f"{page_path.stem}.jpg" + converted.save(converted_path, format="JPEG", quality=95) + converted.close() + return converted_path + + def _build_pdf_inputs(self) -> list[str]: + """Build ordered image input paths for img2pdf conversion.""" + if self._temp_dir is None: + return [] + + prepared_dir = Path(self._temp_dir.name) + prepared_paths: list[str] = [] + for page_path in sorted(self._page_paths): + prepared_paths.append(str(self._prepare_page_for_pdf(page_path, prepared_dir))) + return prepared_paths + + def close(self) -> None: + """Write all collected images to the destination PDF file.""" + if self.skip_all_images: + return + + app_info = f"{__title__} - {__version__}" + temp_pdf_path: Path | None = None + try: + if not self._page_paths: + return + pdf_inputs = self._build_pdf_inputs() + if not pdf_inputs: + return + with NamedTemporaryFile( + "wb", + delete=False, + dir=self.path.parent, + prefix=f".{self.path.stem}.", + suffix=".tmp", + ) as output_file: + temp_pdf_path = Path(output_file.name) + img2pdf.convert( + pdf_inputs, + outputstream=output_file, + title=self.chapter_name, + producer=app_info, + creator=app_info, + ) + temp_pdf_path.replace(self.path) + finally: + if temp_pdf_path is not None: + with suppress(FileNotFoundError): + temp_pdf_path.unlink() + if self._temp_dir is not None: + self._temp_dir.cleanup() + self._temp_dir = None + self._page_paths.clear() + + def discard(self) -> None: + """Clean buffered page files without publishing a PDF artifact.""" + if self._temp_dir is not None: + self._temp_dir.cleanup() + self._temp_dir = None + self._page_paths.clear() diff --git a/mloader/exporters/raw_exporter.py b/mloader/exporters/raw_exporter.py new file mode 100644 index 0000000..7aca5c7 --- /dev/null +++ b/mloader/exporters/raw_exporter.py @@ -0,0 +1,51 @@ +"""Raw image exporter implementation.""" + +from __future__ import annotations + +from pathlib import Path + +from mloader.exporters.exporter_base import ExporterBase +from mloader.types import ChapterLike, PageIndex, TitleLike + + +class RawExporter(ExporterBase): + """Export manga pages as standalone image files.""" + + format = "raw" + + def __init__( + self, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + ) -> None: + """Initialize output directories for raw image export.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + add_language_to_chapter_name=add_language_to_chapter_name, + ) + self.path = Path(self.destination, self.title_name) + self.path.mkdir(parents=True, exist_ok=True) + + if self.add_chapter_subdir: + self.path = self.path.joinpath(self.chapter_name) + self.path.mkdir(parents=True, exist_ok=True) + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Write one page image file to disk.""" + filename = self.format_page_name(index) + self.path.joinpath(filename).write_bytes(image_data) + + def skip_image(self, index: PageIndex) -> bool: + """Return whether the target image file already exists.""" + filename = self.format_page_name(index) + return self.path.joinpath(filename).exists() diff --git a/mloader/infrastructure/__init__.py b/mloader/infrastructure/__init__.py new file mode 100644 index 0000000..1f0ec41 --- /dev/null +++ b/mloader/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure adapters for external systems and persistence.""" diff --git a/mloader/infrastructure/mangaplus/__init__.py b/mloader/infrastructure/mangaplus/__init__.py new file mode 100644 index 0000000..bf1f530 --- /dev/null +++ b/mloader/infrastructure/mangaplus/__init__.py @@ -0,0 +1 @@ +"""MangaPlus API and discovery adapters.""" diff --git a/mloader/infrastructure/mangaplus/api_response.py b/mloader/infrastructure/mangaplus/api_response.py new file mode 100644 index 0000000..6a5b58e --- /dev/null +++ b/mloader/infrastructure/mangaplus/api_response.py @@ -0,0 +1,263 @@ +"""MangaPlus API response classification helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any, Literal, Protocol + +from google.protobuf.message import DecodeError + +from mloader.response_pb2 import Response + +ApiPayloadKind = Literal["success", "api_error", "empty", "unknown"] + +_ERROR_CODE_PATTERN = re.compile(r"\((?P\d+)\)\s*$") +_MAX_PARSE_DEPTH = 8 + + +@dataclass(frozen=True, slots=True) +class MangaPlusApiError: + """Application-level error message embedded in a MangaPlus protobuf payload.""" + + title: str + body: str + code: str | None = None + language: int | None = None + + +@dataclass(frozen=True, slots=True) +class ApiPayloadClassification: + """Typed classification for raw MangaPlus response bytes.""" + + kind: ApiPayloadKind + error: MangaPlusApiError | None = None + + @property + def description(self) -> str: + """Return a concise human-readable classification summary.""" + if self.kind == "api_error" and self.error is not None: + code = f" code={self.error.code}" if self.error.code else "" + return f"api_error{code}: {self.error.title}: {self.error.body}" + return self.kind + + +class SuccessEnvelopeLike(Protocol): + """Minimal parsed response shape needed for success-envelope checks.""" + + success: Any + + def HasField(self, field_name: str) -> bool: + """Return whether a protobuf field is populated.""" + + +def classify_api_response_payload(payload: bytes) -> ApiPayloadClassification: + """Classify raw MangaPlus payload bytes without assuming a success schema.""" + if not payload: + return ApiPayloadClassification(kind="empty") + + api_error = extract_api_error(payload) + if api_error is not None: + return ApiPayloadClassification(kind="api_error", error=api_error) + + try: + parsed = Response.FromString(payload) + except DecodeError: + return ApiPayloadClassification(kind="unknown") + + if _has_populated_success(parsed): + return ApiPayloadClassification(kind="success") + + return ApiPayloadClassification(kind="unknown") + + +def format_api_payload_problem( + classification: ApiPayloadClassification, + *, + context: str, +) -> str: + """Format a stable diagnostic message for non-success API payloads.""" + if classification.kind == "api_error" and classification.error is not None: + error = classification.error + code = f" ({error.code})" if error.code else "" + return ( + f"MangaPlus API returned an application error for {context}{code}: " + f"{error.title}: {error.body}" + ) + + if classification.kind == "empty": + return f"MangaPlus API returned an empty payload for {context}." + + return ( + f"MangaPlus API returned an undecodable or unknown payload for {context}; " + "this may indicate API schema drift." + ) + + +def extract_api_error(payload: bytes) -> MangaPlusApiError | None: + """Extract the first useful MangaPlus application error envelope, if present.""" + for field_number, wire_type, value in _iter_fields(payload): + if field_number != 2 or wire_type != 2 or not isinstance(value, bytes): + continue + + messages = [ + message for message in _extract_error_messages(value) if message.body or message.title + ] + if not messages: + continue + + for message in messages: + if message.language in (None, 0) and _looks_english(message.body): + return message + return messages[0] + + return None + + +def _has_populated_success(parsed: SuccessEnvelopeLike) -> bool: + """Return whether ``parsed`` contains a non-empty success envelope.""" + try: + if not parsed.HasField("success"): + return False + except ValueError: + return False + return bool(parsed.success.ListFields()) + + +def _extract_error_messages(error_payload: bytes) -> list[MangaPlusApiError]: + """Extract localized error messages from the raw top-level error branch.""" + messages: list[MangaPlusApiError] = [] + for _field_number, wire_type, value in _iter_fields(error_payload): + if wire_type != 2 or not isinstance(value, bytes): + continue + message = _parse_error_message(value) + if message is not None: + messages.append(message) + return messages + + +def _parse_error_message(payload: bytes) -> MangaPlusApiError | None: + """Parse one localized API error message from a raw protobuf submessage.""" + title = "" + body = "" + language: int | None = None + + for field_number, wire_type, value in _iter_fields(payload): + if wire_type == 2 and isinstance(value, bytes): + if field_number == 1: + title = _decode_text(value) + elif field_number == 2: + body = _decode_text(value) + elif wire_type == 0 and isinstance(value, int) and field_number == 6: + language = value + + if not title and not body: + return None + + return MangaPlusApiError( + title=title, + body=body, + code=_extract_error_code(body), + language=language, + ) + + +def _extract_error_code(message: str) -> str | None: + """Return a trailing MangaPlus numeric error code from ``message``.""" + match = _ERROR_CODE_PATTERN.search(message) + if match is None: + return None + return match.group("code") + + +def _looks_english(text: str) -> bool: + """Return a lightweight signal for the English localized error branch.""" + if not text: + return False + return text.isascii() and "Manga" in text + + +def _decode_text(value: bytes) -> str: + """Decode protobuf string bytes defensively.""" + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return "" + + +def _iter_fields( + payload: bytes, + *, + depth: int = 0, +) -> list[tuple[int, int, int | bytes]]: + """Return raw protobuf fields for the wire types used by MangaPlus envelopes.""" + if depth > _MAX_PARSE_DEPTH: + return [] + + fields: list[tuple[int, int, int | bytes]] = [] + index = 0 + length = len(payload) + while index < length: + try: + key, index = _read_varint(payload, index) + except ValueError: + return fields + + field_number = key >> 3 + wire_type = key & 0x07 + if field_number <= 0: + return fields + + if wire_type == 0: + try: + value, index = _read_varint(payload, index) + except ValueError: + return fields + fields.append((field_number, wire_type, value)) + continue + + if wire_type == 1: + if index + 8 > length: + return fields + index += 8 + fields.append((field_number, wire_type, b"")) + continue + + if wire_type == 2: + try: + size, index = _read_varint(payload, index) + except ValueError: + return fields + end = index + size + if end > length: + return fields + fields.append((field_number, wire_type, payload[index:end])) + index = end + continue + + if wire_type == 5: + if index + 4 > length: + return fields + index += 4 + fields.append((field_number, wire_type, b"")) + continue + + return fields + + return fields + + +def _read_varint(payload: bytes, index: int) -> tuple[int, int]: + """Read a protobuf varint from ``payload`` starting at ``index``.""" + shift = 0 + value = 0 + while index < len(payload): + byte = payload[index] + index += 1 + value |= (byte & 0x7F) << shift + if byte < 0x80: + return value, index + shift += 7 + if shift >= 64: + raise ValueError("varint too long") + raise ValueError("truncated varint") diff --git a/mloader/infrastructure/mangaplus/auth.py b/mloader/infrastructure/mangaplus/auth.py new file mode 100644 index 0000000..4fdc15b --- /dev/null +++ b/mloader/infrastructure/mangaplus/auth.py @@ -0,0 +1,12 @@ +"""MangaPlus auth query-parameter accessors.""" + +from __future__ import annotations + +from mloader import config +from mloader.config import AuthSettings + + +def auth_params(settings: AuthSettings | None = None) -> dict[str, str]: + """Return MangaPlus auth settings as query parameters.""" + resolved_settings = settings if settings is not None else config.AUTH_SETTINGS + return resolved_settings.as_query_params() diff --git a/mloader/infrastructure/mangaplus/browser_discovery.py b/mloader/infrastructure/mangaplus/browser_discovery.py new file mode 100644 index 0000000..e5be3e5 --- /dev/null +++ b/mloader/infrastructure/mangaplus/browser_discovery.py @@ -0,0 +1,36 @@ +"""Browser-rendered MangaPlus list-page title discovery.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from mloader.infrastructure.mangaplus.static_discovery import extract_title_ids + + +def collect_title_ids_with_browser( + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, +) -> list[int]: + """Render list pages in a browser and extract title IDs from DOM links.""" + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: # pragma: no cover - import path is covered by CLI tests + raise RuntimeError( + "Playwright is not installed. Install project dependencies with 'uv sync' and run " + "'playwright install chromium'." + ) from exc + + title_ids: set[int] = set() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + for page_url in pages: + page.goto(page_url, wait_until="networkidle", timeout=timeout_ms) + for link in page.query_selector_all("a[href]"): + href = link.get_attribute("href") + if href: + title_ids.update(extract_title_ids(href, id_length=id_length)) + browser.close() + return sorted(title_ids) diff --git a/mloader/infrastructure/mangaplus/capture.py b/mloader/infrastructure/mangaplus/capture.py new file mode 100644 index 0000000..3d1434d --- /dev/null +++ b/mloader/infrastructure/mangaplus/capture.py @@ -0,0 +1,113 @@ +"""API payload capture helpers for reproducible fixture generation.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from hashlib import sha256 +from itertools import count +from pathlib import Path +from typing import Mapping + +from google.protobuf.json_format import MessageToDict + +from mloader.infrastructure.mangaplus.api_response import classify_api_response_payload +from mloader.response_pb2 import Response + +_FILENAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+") +_REDACTED_PARAM_KEYS = frozenset( + { + "secret", + "authorization", + "auth", + "token", + "cookie", + "session", + } +) + + +def _sanitize_filename(value: str) -> str: + """Return a filesystem-safe slug for capture file names.""" + sanitized = _FILENAME_SANITIZER.sub("_", value.strip()) + return sanitized.strip("._") or "capture" + + +def _redact_params(params: Mapping[str, object]) -> dict[str, object]: + """Return a sorted parameter mapping with sensitive values redacted.""" + redacted: dict[str, object] = {} + for key, value in sorted(params.items(), key=lambda item: item[0]): + redacted[key] = "***REDACTED***" if key.lower() in _REDACTED_PARAM_KEYS else value + return redacted + + +class APIPayloadCapture: + """Persist raw and parsed API payloads for later regression analysis.""" + + def __init__(self, capture_dir: str | Path) -> None: + """Initialize capture directory and sequence counter.""" + self.capture_dir = Path(capture_dir) + self.capture_dir.mkdir(parents=True, exist_ok=True) + self._sequence = count(1) + + def capture( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Write capture metadata, raw protobuf payload, and optional parsed JSON.""" + sequence = next(self._sequence) + capture_stem = ( + f"{sequence:04d}_{_sanitize_filename(endpoint)}_{_sanitize_filename(str(identifier))}" + ) + raw_payload_path = self.capture_dir / f"{capture_stem}.pb" + metadata_path = self.capture_dir / f"{capture_stem}.meta.json" + parsed_response_path = self.capture_dir / f"{capture_stem}.response.json" + + raw_payload_path.write_bytes(response_content) + + metadata: dict[str, object] = { + "captured_at_utc": datetime.now(timezone.utc).isoformat(), + "endpoint": endpoint, + "identifier": str(identifier), + "url": url, + "params": _redact_params(params), + "payload_sha256": sha256(response_content).hexdigest(), + "payload_size_bytes": len(response_content), + "raw_payload_file": raw_payload_path.name, + } + classification = classify_api_response_payload(response_content) + metadata["payload_classification"] = classification.kind + if classification.error is not None: + metadata["api_error"] = { + "title": classification.error.title, + "body": classification.error.body, + "code": classification.error.code, + "language": classification.error.language, + } + + try: + parsed_response = Response.FromString(response_content) + parsed_dict = MessageToDict( + parsed_response, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + except Exception as exc: + metadata["parsed_payload_error"] = str(exc) + else: + parsed_response_path.write_text( + json.dumps(parsed_dict, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + metadata["parsed_payload_file"] = parsed_response_path.name + + metadata_path.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) diff --git a/mloader/infrastructure/mangaplus/capture_metadata.py b/mloader/infrastructure/mangaplus/capture_metadata.py new file mode 100644 index 0000000..8725719 --- /dev/null +++ b/mloader/infrastructure/mangaplus/capture_metadata.py @@ -0,0 +1,75 @@ +"""Capture metadata loading and payload integrity checks.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any, cast + +SUPPORTED_ENDPOINTS = frozenset({"manga_viewer", "title_detailV3", "title_index"}) + + +class CaptureVerificationError(ValueError): + """Raised when a capture set fails schema verification.""" + + +@dataclass(frozen=True) +class CapturePayload: + """Raw capture payload with validated metadata.""" + + stem: str + endpoint: str + metadata: dict[str, Any] + payload: bytes + raw_payload_file: str + + +def load_metadata(meta_path: Path) -> dict[str, Any]: + """Load and return metadata JSON as a dictionary.""" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if not isinstance(metadata, dict): + raise CaptureVerificationError(f"Metadata file is not an object: {meta_path}") + return cast(dict[str, Any], metadata) + + +def load_capture_payload(capture_dir_path: Path, meta_path: Path) -> CapturePayload: + """Load capture metadata and verify the referenced raw payload.""" + metadata = load_metadata(meta_path) + stem = meta_path.name.removesuffix(".meta.json") + + endpoint = str(metadata.get("endpoint", "")) + if not endpoint: + raise CaptureVerificationError(f"Missing endpoint in metadata file: {meta_path.name}") + if endpoint not in SUPPORTED_ENDPOINTS: + raise CaptureVerificationError( + f"Unsupported endpoint '{endpoint}' in metadata {meta_path.name}" + ) + + raw_payload_file = str(metadata.get("raw_payload_file", f"{stem}.pb")) + raw_payload_path = capture_dir_path / raw_payload_file + if not raw_payload_path.exists(): + raise CaptureVerificationError( + f"Missing raw payload file referenced by metadata: {raw_payload_file}" + ) + + payload = raw_payload_path.read_bytes() + payload_size = metadata.get("payload_size_bytes") + if isinstance(payload_size, int) and payload_size != len(payload): + raise CaptureVerificationError( + f"Payload size mismatch for {raw_payload_file}: " + f"metadata={payload_size}, actual={len(payload)}" + ) + + payload_sha = metadata.get("payload_sha256") + if isinstance(payload_sha, str) and payload_sha != sha256(payload).hexdigest(): + raise CaptureVerificationError(f"Payload sha256 mismatch for {raw_payload_file}") + + return CapturePayload( + stem=stem, + endpoint=endpoint, + metadata=metadata, + payload=payload, + raw_payload_file=raw_payload_file, + ) diff --git a/mloader/infrastructure/mangaplus/capture_payload_validation.py b/mloader/infrastructure/mangaplus/capture_payload_validation.py new file mode 100644 index 0000000..533b0a2 --- /dev/null +++ b/mloader/infrastructure/mangaplus/capture_payload_validation.py @@ -0,0 +1,76 @@ +"""Runtime-field validation for captured MangaPlus protobuf payloads.""" + +from __future__ import annotations + +from typing import Any + +from mloader.infrastructure.mangaplus.capture_metadata import CaptureVerificationError +from mloader.response_pb2 import Response + + +def verify_title_detail_payload(parsed: Response, stem: str) -> None: + """Validate required title-detail fields used by planner/download flow.""" + if not parsed.success.HasField("title_detail_view"): + raise CaptureVerificationError(f"Missing success.title_detail_view in {stem}.pb") + + title_detail = parsed.success.title_detail_view + if title_detail.title.title_id == 0 or not title_detail.title.name: + raise CaptureVerificationError(f"Missing required title identity fields in {stem}.pb") + + has_grouped_chapter = any( + group.first_chapter_list or group.mid_chapter_list or group.last_chapter_list + for group in title_detail.chapter_list_group + ) + if has_grouped_chapter or title_detail.chapter_list: + return + + if not title_detail.chapter_list_group: + raise CaptureVerificationError( + f"No chapter_list_group records or flat chapter_list records in {stem}.pb" + ) + + if not has_grouped_chapter: + raise CaptureVerificationError( + f"No chapter entries found in chapter_list_group for {stem}.pb" + ) + + +def verify_manga_viewer_payload( + parsed: Response, + stem: str, + *, + metadata: dict[str, Any] | None = None, +) -> None: + """Validate required manga-viewer fields used by download flow.""" + if not parsed.success.HasField("manga_viewer"): + raise CaptureVerificationError(f"Missing success.manga_viewer in {stem}.pb") + + viewer = parsed.success.manga_viewer + if viewer.title_id == 0 or viewer.chapter_id == 0: + raise CaptureVerificationError(f"Missing viewer title_id/chapter_id fields in {stem}.pb") + + if not viewer.pages: + metadata = metadata or {} + if metadata.get("expected_runtime_error") == "subscription_required": + return + raise CaptureVerificationError(f"No pages found in manga_viewer payload {stem}.pb") + + if not any(page.manga_page.image_url for page in viewer.pages): + raise CaptureVerificationError(f"No manga_page.image_url found in pages for {stem}.pb") + + last_page = viewer.pages[-1].last_page + if last_page.current_chapter.chapter_id == 0: + raise CaptureVerificationError(f"Missing last_page.current_chapter in {stem}.pb") + + +def verify_title_index_payload(parsed: Response, stem: str) -> None: + """Validate required title-index fields used by ``--all`` discovery.""" + if not parsed.success.HasField("all_titles_view"): + raise CaptureVerificationError(f"Missing success.all_titles_view in {stem}.pb") + + all_titles = parsed.success.all_titles_view + if not all_titles.title_groups: + raise CaptureVerificationError(f"No title_groups records in {stem}.pb") + + if not any(group.titles for group in all_titles.title_groups): + raise CaptureVerificationError(f"No title records found in title_groups for {stem}.pb") diff --git a/mloader/infrastructure/mangaplus/capture_signatures.py b/mloader/infrastructure/mangaplus/capture_signatures.py new file mode 100644 index 0000000..6729867 --- /dev/null +++ b/mloader/infrastructure/mangaplus/capture_signatures.py @@ -0,0 +1,189 @@ +"""Schema-signature builders for MangaPlus capture verification.""" + +from __future__ import annotations + +import json +from typing import Any, cast +from urllib.parse import urlparse + +from google.protobuf.json_format import MessageToDict +from mloader.infrastructure.mangaplus.api_response import ApiPayloadClassification +from mloader.infrastructure.mangaplus.capture_metadata import CaptureVerificationError +from mloader.response_pb2 import Response + + +def as_dict(value: object, context: str) -> dict[str, Any]: + """Return ``value`` as dictionary or raise descriptive verification error.""" + if not isinstance(value, dict): + raise CaptureVerificationError(f"Expected object at {context}") + return cast(dict[str, Any], value) + + +def as_list(value: object, context: str) -> list[Any]: + """Return ``value`` as list or raise descriptive verification error.""" + if not isinstance(value, list): + raise CaptureVerificationError(f"Expected list at {context}") + return cast(list[Any], value) + + +def build_schema_signature( + *, + endpoint: str, + metadata: dict[str, Any], + parsed: Response, +) -> str: + """Build normalized schema signature JSON for baseline drift checks.""" + response_data = MessageToDict( + parsed, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + success = as_dict(response_data.get("success"), "response.success") + params = as_dict(metadata.get("params"), "metadata.params") + + signature: dict[str, object] = { + "endpoint": endpoint, + "url_path": urlparse(str(metadata.get("url", ""))).path, + "meta_keys": sorted(metadata.keys()), + "param_keys": sorted(params.keys()), + "success_keys": sorted(success.keys()), + } + + if endpoint == "manga_viewer": + viewer = as_dict(success.get("manga_viewer"), "response.success.manga_viewer") + signature["payload_keys"] = sorted(viewer.keys()) + + pages = as_list(viewer.get("pages", []), "response.success.manga_viewer.pages") + if metadata.get("expected_runtime_error") == "subscription_required": + signature["payload_state"] = "subscription_required" + return json.dumps(signature, sort_keys=True) + + if not pages: + raise CaptureVerificationError( + "Expected at least one page in response.success.manga_viewer.pages" + ) + signature["payload_state"] = "pages" + first_page = as_dict(pages[0], "response.success.manga_viewer.pages[0]") + last_page = as_dict(pages[-1], "response.success.manga_viewer.pages[-1]") + signature["first_page_keys"] = sorted(first_page.keys()) + signature["last_page_keys"] = sorted(last_page.keys()) + signature["manga_page_keys"] = sorted( + as_dict( + first_page.get("manga_page"), "response.success.manga_viewer.pages[0].manga_page" + ).keys() + ) + signature["last_page_payload_keys"] = sorted( + as_dict( + last_page.get("last_page"), "response.success.manga_viewer.pages[-1].last_page" + ).keys() + ) + return json.dumps(signature, sort_keys=True) + + if endpoint == "title_detailV3": + title_detail = as_dict( + success.get("title_detail_view"), "response.success.title_detail_view" + ) + signature["payload_keys"] = sorted(title_detail.keys()) + signature["title_keys"] = sorted( + as_dict(title_detail.get("title"), "response.success.title_detail_view.title").keys() + ) + + chapter_groups = title_detail.get("chapter_list_group") + if chapter_groups: + grouped_chapters = as_list( + chapter_groups, + "response.success.title_detail_view.chapter_list_group", + ) + first_group = as_dict( + grouped_chapters[0], + "response.success.title_detail_view.chapter_list_group[0]", + ) + signature["chapter_source"] = "chapter_list_group" + signature["chapter_group_keys"] = sorted(first_group.keys()) + + first_chapter_list = as_list( + first_group.get("first_chapter_list"), + "response.success.title_detail_view.chapter_list_group[0].first_chapter_list", + ) + if not first_chapter_list: + raise CaptureVerificationError( + "Expected at least one chapter in first_chapter_list for " + "response.success.title_detail_view" + ) + first_chapter = as_dict( + first_chapter_list[0], + "response.success.title_detail_view.chapter_list_group[0].first_chapter_list[0]", + ) + signature["chapter_keys"] = sorted(first_chapter.keys()) + return json.dumps(signature, sort_keys=True) + + flat_chapters = as_list( + title_detail.get("chapter_list", []), + "response.success.title_detail_view.chapter_list", + ) + if not flat_chapters: + raise CaptureVerificationError( + "Expected at least one chapter group or flat chapter list in " + "response.success.title_detail_view" + ) + first_chapter = as_dict( + flat_chapters[0], + "response.success.title_detail_view.chapter_list[0]", + ) + signature["chapter_source"] = "chapter_list" + signature["chapter_keys"] = sorted(first_chapter.keys()) + return json.dumps(signature, sort_keys=True) + + if endpoint == "title_index": + all_titles = as_dict(success.get("all_titles_view"), "response.success.all_titles_view") + signature["payload_keys"] = sorted(all_titles.keys()) + title_groups = as_list( + all_titles.get("title_groups"), + "response.success.all_titles_view.title_groups", + ) + if not title_groups: + raise CaptureVerificationError( + "Expected at least one group in response.success.all_titles_view.title_groups" + ) + first_group = as_dict(title_groups[0], "response.success.all_titles_view.title_groups[0]") + signature["title_group_keys"] = sorted(first_group.keys()) + titles = as_list( + first_group.get("titles"), + "response.success.all_titles_view.title_groups[0].titles", + ) + if not titles: + raise CaptureVerificationError( + "Expected at least one title in response.success.all_titles_view.title_groups[0].titles" + ) + first_title = as_dict( + titles[0], + "response.success.all_titles_view.title_groups[0].titles[0]", + ) + signature["title_keys"] = sorted(first_title.keys()) + return json.dumps(signature, sort_keys=True) + + raise CaptureVerificationError(f"Unsupported endpoint '{endpoint}'") + + +def build_api_error_signature( + *, + endpoint: str, + metadata: dict[str, Any], + classification: ApiPayloadClassification, +) -> str: + """Build normalized signature JSON for captured API error envelopes.""" + if classification.error is None: + raise CaptureVerificationError("Expected API error details in payload classification") + + params = as_dict(metadata.get("params", {}), "metadata.params") + signature: dict[str, object] = { + "endpoint": endpoint, + "url_path": urlparse(str(metadata.get("url", ""))).path, + "meta_keys": sorted(metadata.keys()), + "param_keys": sorted(params.keys()), + "payload_classification": classification.kind, + "api_error_code": classification.error.code, + "api_error_language": classification.error.language, + "api_error_title": classification.error.title, + } + return json.dumps(signature, sort_keys=True) diff --git a/mloader/infrastructure/mangaplus/capture_verify.py b/mloader/infrastructure/mangaplus/capture_verify.py new file mode 100644 index 0000000..11c0bb7 --- /dev/null +++ b/mloader/infrastructure/mangaplus/capture_verify.py @@ -0,0 +1,132 @@ +"""Verification entrypoints for recorded API capture payloads.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from mloader.infrastructure.mangaplus.api_response import classify_api_response_payload +from mloader.infrastructure.mangaplus.capture_metadata import ( + CapturePayload, + CaptureVerificationError, + load_capture_payload, +) +from mloader.infrastructure.mangaplus.capture_payload_validation import ( + verify_manga_viewer_payload, + verify_title_detail_payload, + verify_title_index_payload, +) +from mloader.infrastructure.mangaplus.capture_signatures import ( + build_api_error_signature, + build_schema_signature, +) +from mloader.response_pb2 import Response + + +@dataclass(frozen=True) +class CaptureVerificationSummary: + """Summary produced after capture schema verification.""" + + total_records: int + endpoint_counts: dict[str, int] + + +@dataclass(frozen=True) +class _CaptureRecord: + """Internal verified capture record used for baseline comparison.""" + + stem: str + endpoint: str + signature_json: str + + +def _record_signature(capture: CapturePayload) -> _CaptureRecord: + """Validate one capture payload and return its baseline signature.""" + classification = classify_api_response_payload(capture.payload) + if classification.kind == "api_error": + return _CaptureRecord( + stem=capture.stem, + endpoint=capture.endpoint, + signature_json=build_api_error_signature( + endpoint=capture.endpoint, + metadata=capture.metadata, + classification=classification, + ), + ) + + parsed = Response.FromString(capture.payload) + if not parsed.HasField("success"): + raise CaptureVerificationError(f"Missing success envelope in {capture.raw_payload_file}") + + if capture.endpoint == "title_detailV3": + verify_title_detail_payload(parsed, capture.stem) + elif capture.endpoint == "manga_viewer": + verify_manga_viewer_payload(parsed, capture.stem, metadata=capture.metadata) + elif capture.endpoint == "title_index": + verify_title_index_payload(parsed, capture.stem) + + return _CaptureRecord( + stem=capture.stem, + endpoint=capture.endpoint, + signature_json=build_schema_signature( + endpoint=capture.endpoint, + metadata=capture.metadata, + parsed=parsed, + ), + ) + + +def _verify_capture_schema_records( + capture_dir_path: Path, +) -> tuple[CaptureVerificationSummary, list[_CaptureRecord]]: + """Verify capture directory and return summary with per-record signatures.""" + if not capture_dir_path.exists() or not capture_dir_path.is_dir(): + raise CaptureVerificationError(f"Capture directory not found: {capture_dir_path}") + + meta_paths = sorted(capture_dir_path.glob("*.meta.json")) + if not meta_paths: + raise CaptureVerificationError(f"No '*.meta.json' files found in: {capture_dir_path}") + + endpoint_counts: dict[str, int] = {} + records: list[_CaptureRecord] = [] + for meta_path in meta_paths: + capture = load_capture_payload(capture_dir_path, meta_path) + records.append(_record_signature(capture)) + endpoint_counts[capture.endpoint] = endpoint_counts.get(capture.endpoint, 0) + 1 + + summary = CaptureVerificationSummary( + total_records=sum(endpoint_counts.values()), + endpoint_counts=endpoint_counts, + ) + return summary, records + + +def verify_capture_schema(capture_dir: str | Path) -> CaptureVerificationSummary: + """Verify capture metadata + protobuf payloads against required runtime fields.""" + summary, _records = _verify_capture_schema_records(Path(capture_dir)) + return summary + + +def verify_capture_schema_against_baseline( + capture_dir: str | Path, + baseline_dir: str | Path, +) -> CaptureVerificationSummary: + """Verify captures and compare structural signatures against a baseline set.""" + capture_summary, capture_records = _verify_capture_schema_records(Path(capture_dir)) + _baseline_summary, baseline_records = _verify_capture_schema_records(Path(baseline_dir)) + + baseline_by_endpoint: dict[str, set[str]] = {} + for record in baseline_records: + baseline_by_endpoint.setdefault(record.endpoint, set()).add(record.signature_json) + + for record in capture_records: + if record.endpoint not in baseline_by_endpoint: + raise CaptureVerificationError( + f"Unknown endpoint '{record.endpoint}' in capture '{record.stem}' compared to baseline" + ) + if record.signature_json not in baseline_by_endpoint[record.endpoint]: + raise CaptureVerificationError( + f"Schema drift detected for capture '{record.stem}' endpoint '{record.endpoint}' " + f"when compared to baseline '{baseline_dir}'" + ) + return capture_summary diff --git a/mloader/infrastructure/mangaplus/gateway.py b/mloader/infrastructure/mangaplus/gateway.py new file mode 100644 index 0000000..4f9c95e --- /dev/null +++ b/mloader/infrastructure/mangaplus/gateway.py @@ -0,0 +1,202 @@ +"""MangaPlus HTTP gateway and protobuf-to-domain parsing adapter.""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Callable, Collection, Mapping +from typing import TypeVar, cast + +from requests import Session + +from mloader.domain.manga import MangaViewer, TitleDetail +from mloader.infrastructure.mangaplus import auth +from mloader.infrastructure.mangaplus import parsing as response_parsing +from mloader.infrastructure.mangaplus.settings import ( + DEFAULT_API_BASE_URL, + DEFAULT_REQUEST_TIMEOUT, + DEFAULT_RETRIES, + MANGA_VIEWER_PATH, + TITLE_DETAIL_PATH, + api_url, +) +from mloader.infrastructure.mangaplus.transport import ( + apply_mobile_api_headers, + capture_response_payload, + configure_transport, +) +from mloader.types import PayloadCaptureLike, SessionLike + +AuthParamsProvider = Callable[[], Mapping[str, str]] +CacheValue = TypeVar("CacheValue") + + +class MangaPlusGateway: + """HTTP gateway for MangaPlus title-detail and manga-viewer endpoints.""" + + def __init__( + self, + *, + session: SessionLike | None = None, + api_base_url: str = DEFAULT_API_BASE_URL, + quality: str, + split: bool, + request_timeout: tuple[float, float] = DEFAULT_REQUEST_TIMEOUT, + retries: int = DEFAULT_RETRIES, + payload_capture: PayloadCaptureLike | None = None, + auth_params_provider: AuthParamsProvider = auth.auth_params, + viewer_cache_max_size: int = 512, + title_cache_max_size: int = 256, + ) -> None: + """Initialize transport, auth, payload capture, and response caches.""" + self.session: SessionLike = session if session is not None else cast(SessionLike, Session()) + configure_transport(self.session, retries) + apply_mobile_api_headers(self.session) + self.api_base_url = api_base_url + self.quality = quality + self.split = split + self.request_timeout = request_timeout + self.payload_capture = payload_capture + self.auth_params_provider = auth_params_provider + self.viewer_cache_max_size = viewer_cache_max_size + self.title_cache_max_size = title_cache_max_size + self._viewer_cache: OrderedDict[str, MangaViewer] = OrderedDict() + self._title_cache: OrderedDict[str, TitleDetail] = OrderedDict() + + def load_pages(self, chapter_id: str | int) -> MangaViewer: + """Load and cache manga viewer data for a chapter.""" + cache_key = self._cache_key(chapter_id) + cached_viewer = self._cache_get(self._viewer_cache, cache_key) + if cached_viewer is not None: + return cached_viewer + + url = self.build_manga_viewer_url() + params = self.build_manga_viewer_params(chapter_id) + response = self.session.get(url, params=params, timeout=self.request_timeout) + response.raise_for_status() + self.capture_payload( + endpoint="manga_viewer", + identifier=chapter_id, + url=url, + params=params, + response_content=response.content, + ) + viewer = response_parsing.parse_manga_viewer_response(response.content) + self._cache_set( + self._viewer_cache, + cache_key, + viewer, + max_size=self.viewer_cache_max_size, + ) + return viewer + + def get_title_details(self, title_id: str | int) -> TitleDetail: + """Load and cache title-detail data for a title.""" + cache_key = self._cache_key(title_id) + cached_title = self._cache_get(self._title_cache, cache_key) + if cached_title is not None: + return cached_title + + url = self.build_title_detail_url() + params = self.build_title_detail_params(title_id) + response = self.session.get(url, params=params, timeout=self.request_timeout) + response.raise_for_status() + self.capture_payload( + endpoint="title_detailV3", + identifier=title_id, + url=url, + params=params, + response_content=response.content, + ) + title_detail = response_parsing.parse_title_detail_response(response.content) + self._cache_set( + self._title_cache, + cache_key, + title_detail, + max_size=self.title_cache_max_size, + ) + return title_detail + + def build_manga_viewer_url(self) -> str: + """Construct the full URL for the manga-viewer API endpoint.""" + return api_url(self.api_base_url, MANGA_VIEWER_PATH) + + def build_manga_viewer_params(self, chapter_id: str | int) -> dict[str, str | int]: + """Assemble manga-viewer API query parameters.""" + split_value = "yes" if self.split else "no" + return { + **self.auth_params_provider(), + "chapter_id": chapter_id, + "split": split_value, + "img_quality": self.quality, + } + + def build_title_detail_url(self) -> str: + """Construct the full URL for the title-detail API endpoint.""" + return api_url(self.api_base_url, TITLE_DETAIL_PATH) + + def build_title_detail_params(self, title_id: str | int) -> dict[str, str | int]: + """Assemble title-detail API query parameters.""" + return {**self.auth_params_provider(), "title_id": title_id} + + def capture_payload( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Write API payload capture data when capture mode is enabled.""" + capture_response_payload( + self.payload_capture, + endpoint=endpoint, + identifier=identifier, + url=url, + params=params, + response_content=response_content, + ) + + def clear_run_caches(self) -> None: + """Clear all cached API response DTOs.""" + self._viewer_cache.clear() + self._title_cache.clear() + + def clear_title_caches( + self, + title_id: str | int, + chapter_ids: Collection[int] | None = None, + ) -> None: + """Clear title-scoped API cache entries.""" + self._title_cache.pop(self._cache_key(title_id), None) + if not chapter_ids: + return + for chapter_id in chapter_ids: + self._viewer_cache.pop(self._cache_key(chapter_id), None) + + @staticmethod + def _cache_key(identifier: str | int) -> str: + """Return a normalized cache key for API identifiers.""" + return str(identifier) + + @staticmethod + def _cache_get(cache: OrderedDict[str, CacheValue], key: str) -> CacheValue | None: + """Return cached value by key and refresh LRU order.""" + if key not in cache: + return None + cache.move_to_end(key) + return cache[key] + + @staticmethod + def _cache_set( + cache: OrderedDict[str, CacheValue], + key: str, + value: CacheValue, + *, + max_size: int, + ) -> None: + """Store cached value and evict oldest entries beyond ``max_size``.""" + cache[key] = value + cache.move_to_end(key) + while len(cache) > max_size: + cache.popitem(last=False) diff --git a/mloader/infrastructure/mangaplus/mappers.py b/mloader/infrastructure/mangaplus/mappers.py new file mode 100644 index 0000000..26fdd68 --- /dev/null +++ b/mloader/infrastructure/mangaplus/mappers.py @@ -0,0 +1,167 @@ +"""Map MangaPlus protobuf payload objects into stable domain DTOs.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +from mloader.domain.manga import ( + Chapter, + ChapterGroup, + LastPage, + MangaPage, + MangaViewer, + Title, + TitleDetail, + TitleTag, + ViewerPage, +) + + +def chapter_from_proto(chapter: Any) -> Chapter: + """Map a protobuf chapter object into a domain chapter.""" + return Chapter( + title_id=int(getattr(chapter, "title_id", 0)), + chapter_id=int(getattr(chapter, "chapter_id", 0)), + name=str(getattr(chapter, "name", "")), + sub_title=str(getattr(chapter, "sub_title", "")), + thumbnail_url=str(getattr(chapter, "thumbnail_url", "")), + start_timestamp=int(getattr(chapter, "start_timestamp", 0)), + end_timestamp=int(getattr(chapter, "end_timestamp", 0)), + already_viewed=bool(getattr(chapter, "already_viewed", False)), + is_vertical_only=bool(getattr(chapter, "is_vertical_only", False)), + ) + + +def title_from_proto(title: Any) -> Title: + """Map a protobuf title object into a domain title.""" + return Title( + title_id=int(getattr(title, "title_id", 0)), + name=str(getattr(title, "name", "")), + author=str(getattr(title, "author", "")), + portrait_image_url=str(getattr(title, "portrait_image_url", "")), + landscape_image_url=str(getattr(title, "landscape_image_url", "")), + language=int(getattr(title, "language", 0)), + ) + + +def title_tag_from_proto(tag: Any) -> TitleTag: + """Map a protobuf title tag object into a domain tag.""" + return TitleTag( + name=str(getattr(tag, "name", "")), + slug=str(getattr(tag, "slug", "")), + ) + + +def chapter_group_from_proto(group: Any) -> ChapterGroup: + """Map a protobuf chapter group into a domain chapter group.""" + return ChapterGroup( + first_chapters=tuple( + chapter_from_proto(chapter) for chapter in getattr(group, "first_chapter_list", ()) + ), + mid_chapters=tuple( + chapter_from_proto(chapter) for chapter in getattr(group, "mid_chapter_list", ()) + ), + last_chapters=tuple( + chapter_from_proto(chapter) for chapter in getattr(group, "last_chapter_list", ()) + ), + ) + + +def title_detail_from_proto(title_detail: Any) -> TitleDetail: + """Map a protobuf title-detail view into a domain title detail.""" + overview = str(getattr(title_detail, "overview", "")) + tags = tuple(title_tag_from_proto(tag) for tag in getattr(title_detail, "tags", ())) + sns = getattr(title_detail, "sns", None) + web_url = str(getattr(sns, "url", "")) if sns is not None else "" + title = replace( + title_from_proto(title_detail.title), + overview=overview, + tags=tags, + web_url=web_url, + ) + chapter_groups = tuple( + chapter_group_from_proto(group) for group in getattr(title_detail, "chapter_list_group", ()) + ) + if not any(group.chapters for group in chapter_groups): + flat_chapters = tuple( + chapter_from_proto(chapter) for chapter in getattr(title_detail, "chapter_list", ()) + ) + if flat_chapters: + chapter_groups = ( + ChapterGroup( + first_chapters=flat_chapters, + mid_chapters=(), + last_chapters=(), + ), + ) + + return TitleDetail( + title=title, + title_image_url=str(getattr(title_detail, "title_image_url", "")), + overview=overview, + non_appearance_info=str(getattr(title_detail, "non_appearance_info", "")), + number_of_views=int(getattr(title_detail, "number_of_views", 0)), + chapter_groups=chapter_groups, + ) + + +def manga_page_from_proto(manga_page: Any) -> MangaPage: + """Map a protobuf manga-page object into a domain page.""" + return MangaPage( + image_url=str(getattr(manga_page, "image_url", "")), + width=int(getattr(manga_page, "width", 0)), + height=int(getattr(manga_page, "height", 0)), + page_type=int(getattr(manga_page, "type", 0)), + encryption_key=str(getattr(manga_page, "encryption_key", "")), + ) + + +def _optional_chapter_from_proto(chapter: Any) -> Chapter | None: + """Map a protobuf chapter when it carries an identity, otherwise return ``None``.""" + if int(getattr(chapter, "chapter_id", 0)) == 0: + return None + return chapter_from_proto(chapter) + + +def last_page_from_proto(last_page: Any) -> LastPage | None: + """Map terminal viewer metadata when present.""" + current_chapter = _optional_chapter_from_proto(last_page.current_chapter) + if current_chapter is None: + return None + return LastPage( + current_chapter=current_chapter, + next_chapter=_optional_chapter_from_proto(last_page.next_chapter), + ) + + +def viewer_page_from_proto(page: Any) -> ViewerPage: + """Map a protobuf viewer page envelope into a domain viewer page.""" + manga_page = manga_page_from_proto(page.manga_page) + if not manga_page.image_url: + manga_page = None + return ViewerPage( + manga_page=manga_page, + last_page=last_page_from_proto(page.last_page), + ) + + +def manga_viewer_from_proto(viewer: Any) -> MangaViewer: + """Map a protobuf manga-viewer payload into a domain viewer.""" + return MangaViewer( + title_id=int(getattr(viewer, "title_id", 0)), + chapter_id=int(getattr(viewer, "chapter_id", 0)), + title_name=str(getattr(viewer, "title_name", "")), + chapter_name=str(getattr(viewer, "chapter_name", "")), + chapters=tuple(chapter_from_proto(chapter) for chapter in getattr(viewer, "chapters", ())), + pages=tuple(viewer_page_from_proto(page) for page in getattr(viewer, "pages", ())), + ) + + +def titles_from_all_titles_proto(all_titles: Any) -> tuple[Title, ...]: + """Map a protobuf title-index payload into a flat title tuple.""" + return tuple( + title_from_proto(title) + for title_group in getattr(all_titles, "title_groups", ()) + for title in getattr(title_group, "titles", ()) + ) diff --git a/mloader/infrastructure/mangaplus/parsing.py b/mloader/infrastructure/mangaplus/parsing.py new file mode 100644 index 0000000..275b778 --- /dev/null +++ b/mloader/infrastructure/mangaplus/parsing.py @@ -0,0 +1,117 @@ +"""Parse MangaPlus protobuf response bytes into stable domain DTOs.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from mloader.domain.manga import MangaViewer, TitleDetail +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import mappers +from mloader.infrastructure.mangaplus.api_response import ( + classify_api_response_payload, + format_api_payload_problem, +) +from mloader.response_pb2 import Response + + +class ParsedResponse(Protocol): + """Minimal parsed response object produced by protobuf response types.""" + + success: Any + + +class ResponseType(Protocol): + """Factory protocol for protobuf response classes used by parsers.""" + + @staticmethod + def FromString(content: bytes) -> ParsedResponse: + """Parse raw protobuf bytes into a response message.""" + + +def has_message_field(message: object, field_name: str) -> bool: + """Return whether protobuf ``message`` has explicit ``field_name`` set.""" + has_field = getattr(message, "HasField", None) + if not callable(has_field): + return True + try: + return bool(has_field(field_name)) + except ValueError: + return False + + +def raise_payload_error(content: bytes, *, context: str, payload_name: str) -> None: + """Raise a typed response error for a missing payload branch.""" + classification = classify_api_response_payload(content) + if classification.kind in {"api_error", "empty"}: + kind = "api_error" if classification.kind == "api_error" else "empty" + raise APIResponseError( + format_api_payload_problem(classification, context=context), + kind=kind, + code=classification.error.code if classification.error else None, + ) + + raise APIResponseError( + f"MangaPlus API returned no {payload_name} payload; possible API schema drift.", + kind="unknown", + ) + + +def parse_manga_viewer_response( + content: bytes, + *, + response_type: ResponseType = Response, +) -> MangaViewer: + """Parse and validate a MangaPlus viewer response into a domain DTO.""" + parsed = response_type.FromString(content) + success = parsed.success + if not has_message_field(success, "manga_viewer"): + raise_payload_error( + content, + context="manga_viewer", + payload_name="manga_viewer", + ) + + viewer = success.manga_viewer + if viewer.title_id == 0 or viewer.chapter_id == 0: + raise APIResponseError( + "MangaPlus API returned manga_viewer payload without title/chapter IDs.", + kind="unknown", + ) + return mappers.manga_viewer_from_proto(viewer) + + +def parse_title_detail_response( + content: bytes, + *, + response_type: ResponseType = Response, +) -> TitleDetail: + """Parse and validate a MangaPlus title-detail response into a domain DTO.""" + parsed = response_type.FromString(content) + success = parsed.success + if not has_message_field(success, "title_detail_view"): + raise_payload_error( + content, + context="title_detailV3", + payload_name="title_detail_view", + ) + + title_detail = success.title_detail_view + title = title_detail.title + if title.title_id == 0 or not title.name: + raise APIResponseError( + "MangaPlus API returned title_detail_view without title identity.", + kind="unknown", + ) + + mapped = mappers.title_detail_from_proto(title_detail) + if not mapped.chapter_groups: + raise APIResponseError( + "MangaPlus API returned title_detail_view without chapter groups or flat chapter list.", + kind="unknown", + ) + if not mapped.chapters: + raise APIResponseError( + "MangaPlus API returned title_detail_view without chapter entries.", + kind="unknown", + ) + return mapped diff --git a/mloader/infrastructure/mangaplus/settings.py b/mloader/infrastructure/mangaplus/settings.py new file mode 100644 index 0000000..fe44738 --- /dev/null +++ b/mloader/infrastructure/mangaplus/settings.py @@ -0,0 +1,32 @@ +"""Central MangaPlus API endpoint, header, timeout, and retry settings.""" + +from __future__ import annotations + +DEFAULT_API_BASE_URL = "https://jumpg-api.tokyo-cdn.com" +MANGA_VIEWER_PATH = "/api/manga_viewer" +TITLE_DETAIL_PATH = "/api/title_detailV3" +TITLE_INDEX_PATH = "/api/title_list/allV2" + +DEFAULT_LIST_PAGES: tuple[str, str, str] = ( + "https://mangaplus.shueisha.co.jp/manga_list/ongoing", + "https://mangaplus.shueisha.co.jp/manga_list/completed", + "https://mangaplus.shueisha.co.jp/manga_list/one_shot", +) +DEFAULT_TITLE_INDEX_ENDPOINT = f"{DEFAULT_API_BASE_URL}{TITLE_INDEX_PATH}" +DEFAULT_REQUEST_TIMEOUT = (5.0, 30.0) + +MOBILE_API_HEADERS: dict[str, str] = { + "User-Agent": "okhttp/4.12.0", + "Accept-Encoding": "gzip", +} + +RETRY_STATUS_CODES: tuple[int, ...] = (429, 500, 502, 503, 504) +DEFAULT_RETRIES = 3 +RETRY_BACKOFF_FACTOR = 0.5 +TITLE_INDEX_MAX_ATTEMPTS = 3 +TITLE_INDEX_RETRY_BACKOFF_SECONDS = 2.0 + + +def api_url(base_url: str, path: str) -> str: + """Build a MangaPlus API URL from a base URL and path.""" + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" diff --git a/mloader/infrastructure/mangaplus/static_discovery.py b/mloader/infrastructure/mangaplus/static_discovery.py new file mode 100644 index 0000000..0aae46a --- /dev/null +++ b/mloader/infrastructure/mangaplus/static_discovery.py @@ -0,0 +1,40 @@ +"""Static MangaPlus list-page title discovery.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence + +import requests + +from mloader.infrastructure.mangaplus import settings + +# Match both '/titles/123' and escaped '\/titles\/123' shapes. +TITLE_ID_PATTERN = re.compile(r"\\?/titles\\?/(?P\d+)(?:\\?/|$|[?#\"'])") + + +def extract_title_ids(html: str, id_length: int | None = 6) -> set[int]: + """Extract unique MangaPlus title IDs from HTML content.""" + title_ids: set[int] = set() + for match in TITLE_ID_PATTERN.finditer(html): + title_id = match.group("title_id") + if id_length is not None and len(title_id) != id_length: + continue + title_ids.add(int(title_id)) + return title_ids + + +def collect_title_ids( + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = settings.DEFAULT_REQUEST_TIMEOUT, +) -> list[int]: + """Fetch configured list pages and return sorted unique title IDs.""" + title_ids: set[int] = set() + with requests.Session() as session: + for page_url in pages: + response = session.get(page_url, timeout=request_timeout) + response.raise_for_status() + title_ids.update(extract_title_ids(response.text, id_length=id_length)) + return sorted(title_ids) diff --git a/mloader/infrastructure/mangaplus/title_discovery.py b/mloader/infrastructure/mangaplus/title_discovery.py new file mode 100644 index 0000000..5475c4d --- /dev/null +++ b/mloader/infrastructure/mangaplus/title_discovery.py @@ -0,0 +1,73 @@ +"""MangaPlus title-discovery gateway for ``mloader --all`` mode.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from mloader.infrastructure.mangaplus import ( + browser_discovery, + settings, + static_discovery, + title_index, +) + +DEFAULT_LIST_PAGES = settings.DEFAULT_LIST_PAGES +DEFAULT_TITLE_INDEX_ENDPOINT = title_index.DEFAULT_TITLE_INDEX_ENDPOINT +LANGUAGE_FILTER_CHOICES = title_index.LANGUAGE_FILTER_CHOICES + + +class MangaPlusTitleDiscoveryGateway: + """Concrete title-discovery gateway backed by MangaPlus API and list pages.""" + + def parse_language_filters(self, languages: Sequence[str]) -> set[int] | None: + """Map user-facing language names to MangaPlus language codes.""" + return title_index.parse_language_filters(languages) + + def collect_title_ids_from_api( + self, + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = settings.DEFAULT_REQUEST_TIMEOUT, + capture_api_dir: str | None = None, + ) -> list[int]: + """Collect title IDs from the MangaPlus title-index API.""" + return title_index.collect_title_ids_from_api( + title_index_endpoint, + id_length=id_length, + allowed_languages=allowed_languages, + request_timeout=request_timeout, + capture_api_dir=capture_api_dir, + ) + + def collect_title_ids( + self, + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = settings.DEFAULT_REQUEST_TIMEOUT, + ) -> list[int]: + """Collect title IDs from static MangaPlus list pages.""" + return static_discovery.collect_title_ids( + pages, + id_length=id_length, + request_timeout=request_timeout, + ) + + def collect_title_ids_with_browser( + self, + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, + ) -> list[int]: + """Collect title IDs from browser-rendered MangaPlus list pages.""" + return browser_discovery.collect_title_ids_with_browser( + pages, + id_length=id_length, + timeout_ms=timeout_ms, + ) + + +DEFAULT_GATEWAY = MangaPlusTitleDiscoveryGateway() diff --git a/mloader/infrastructure/mangaplus/title_index.py b/mloader/infrastructure/mangaplus/title_index.py new file mode 100644 index 0000000..ab70d68 --- /dev/null +++ b/mloader/infrastructure/mangaplus/title_index.py @@ -0,0 +1,150 @@ +"""MangaPlus title-index API discovery.""" + +from __future__ import annotations + +import time +from collections.abc import Sequence + +import requests + +from mloader.constants import Language +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import auth, settings +from mloader.infrastructure.mangaplus.api_response import ( + classify_api_response_payload, + format_api_payload_problem, +) +from mloader.infrastructure.mangaplus.capture import APIPayloadCapture +from mloader.infrastructure.mangaplus.transport import ( + apply_mobile_api_headers, + capture_response_payload, +) +from mloader.response_pb2 import Response + +DEFAULT_TITLE_INDEX_ENDPOINT = settings.DEFAULT_TITLE_INDEX_ENDPOINT +LANGUAGE_FILTER_CODES: dict[str, set[int]] = { + language.name.lower(): {language.value} for language in Language +} +LANGUAGE_FILTER_CODES["vietnamese"].add(8) +LANGUAGE_FILTER_CHOICES = tuple(LANGUAGE_FILTER_CODES) +API_RETRY_STATUS_CODES: set[int] = set(settings.RETRY_STATUS_CODES) +API_MAX_ATTEMPTS = settings.TITLE_INDEX_MAX_ATTEMPTS +API_RETRY_BACKOFF_SECONDS = settings.TITLE_INDEX_RETRY_BACKOFF_SECONDS + + +def parse_language_filters(languages: Sequence[str]) -> set[int] | None: + """Convert language filter strings into a set of numeric API language codes.""" + if not languages: + return None + + language_codes: set[int] = set() + for language in languages: + language_codes.update(LANGUAGE_FILTER_CODES[language.lower()]) + return language_codes + + +def extract_title_ids_from_api_payload(payload: bytes, id_length: int | None = 6) -> set[int]: + """Extract unique MangaPlus title IDs from protobuf all-titles payload bytes.""" + return extract_title_ids_from_api_payload_with_language_filter( + payload, + id_length=id_length, + allowed_languages=None, + ) + + +def extract_title_ids_from_api_payload_with_language_filter( + payload: bytes, + *, + id_length: int | None, + allowed_languages: set[int] | None, +) -> set[int]: + """Extract unique title IDs with an optional language-code filter.""" + classification = classify_api_response_payload(payload) + if classification.kind != "success": + raise APIResponseError( + format_api_payload_problem(classification, context="title_index"), + kind=classification.kind if classification.kind != "success" else "unknown", + code=classification.error.code if classification.error else None, + ) + + parsed = Response.FromString(payload) + if not parsed.success.HasField("all_titles_view"): + raise APIResponseError( + "MangaPlus title-index API returned a success payload without all_titles_view; " + "this may indicate API schema drift.", + kind="unknown", + ) + + title_ids: set[int] = set() + for title_group in parsed.success.all_titles_view.title_groups: + for title in title_group.titles: + title_id = title.title_id + if allowed_languages is not None and title.language not in allowed_languages: + continue + if title_id <= 0: + continue + if id_length is not None and len(str(title_id)) != id_length: + continue + title_ids.add(int(title_id)) + return title_ids + + +def collect_title_ids_from_api( + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = settings.DEFAULT_REQUEST_TIMEOUT, + capture_api_dir: str | None = None, +) -> list[int]: + """Fetch mobile title-index payload and return sorted unique title IDs.""" + with requests.Session() as session: + apply_mobile_api_headers(session) + last_error: requests.RequestException | None = None + payload_capture = APIPayloadCapture(capture_api_dir) if capture_api_dir else None + for attempt in range(1, API_MAX_ATTEMPTS + 1): + try: + auth_params = auth.auth_params() + response = session.get( + title_index_endpoint, + params=auth_params, + timeout=request_timeout, + ) + response.raise_for_status() + capture_response_payload( + payload_capture, + endpoint="title_index", + identifier="all", + url=title_index_endpoint, + params={ + **auth_params, + "allowed_languages": sorted(allowed_languages) + if allowed_languages is not None + else "all", + "id_length": id_length if id_length is not None else "any", + }, + response_content=response.content, + ) + title_ids = extract_title_ids_from_api_payload_with_language_filter( + response.content, + id_length=id_length, + allowed_languages=allowed_languages, + ) + return sorted(title_ids) + except requests.HTTPError as error: + status_code = error.response.status_code if error.response is not None else None + if status_code in API_RETRY_STATUS_CODES and attempt < API_MAX_ATTEMPTS: + time.sleep(API_RETRY_BACKOFF_SECONDS * attempt) + last_error = error + continue + raise + except requests.RequestException as error: + if attempt < API_MAX_ATTEMPTS: + time.sleep(API_RETRY_BACKOFF_SECONDS * attempt) + last_error = error + continue + raise + + if last_error is not None: # pragma: no cover - final retry raises in-loop + raise last_error + return [] # pragma: no cover - API_MAX_ATTEMPTS is always positive diff --git a/mloader/infrastructure/mangaplus/transport.py b/mloader/infrastructure/mangaplus/transport.py new file mode 100644 index 0000000..9250c6f --- /dev/null +++ b/mloader/infrastructure/mangaplus/transport.py @@ -0,0 +1,57 @@ +"""Shared MangaPlus HTTP transport and capture helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from mloader.infrastructure.mangaplus.settings import ( + MOBILE_API_HEADERS, + RETRY_BACKOFF_FACTOR, + RETRY_STATUS_CODES, +) +from mloader.types import PayloadCaptureLike, SessionLike + + +def configure_transport(session: SessionLike, retries: int) -> None: + """Configure HTTPS retry policy for transient API failures.""" + retry_policy = Retry( + total=retries, + connect=retries, + read=retries, + status=retries, + backoff_factor=RETRY_BACKOFF_FACTOR, + status_forcelist=RETRY_STATUS_CODES, + allowed_methods=frozenset({"GET"}), + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry_policy) + session.mount("https://", adapter) + + +def apply_mobile_api_headers(session: SessionLike) -> None: + """Apply MangaPlus mobile API headers to an HTTP session.""" + session.headers.update(MOBILE_API_HEADERS) + + +def capture_response_payload( + payload_capture: PayloadCaptureLike | None, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, +) -> None: + """Persist an API payload capture record when capture mode is enabled.""" + if payload_capture is None: + return + payload_capture.capture( + endpoint=endpoint, + identifier=identifier, + url=url, + params=params, + response_content=response_content, + ) diff --git a/mloader/loader.py b/mloader/loader.py deleted file mode 100644 index 25041d7..0000000 --- a/mloader/loader.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -from collections import namedtuple -from functools import lru_cache -from itertools import chain, count -from typing import Union, Dict, Set, Collection, Optional, Callable - -import click -from requests import Session - -from mloader.constants import PageType -from mloader.exporter import ExporterBase -from mloader.response_pb2 import ( - Response, - MangaViewer, - TitleDetailView, - Chapter, - Title, -) -from mloader.utils import chapter_name_to_int - -log = logging.getLogger() - -MangaList = Dict[int, Set[int]] # Title ID: Set[Chapter ID] - - -class MangaLoader: - def __init__( - self, - exporter: Callable[[Title, Chapter, Optional[Chapter]], ExporterBase], - quality: str = "super_high", - split: bool = False, - ): - self.exporter = exporter - self.quality = quality - self.split = split - self._api_url = "https://jumpg-webapi.tokyo-cdn.com" - self.session = Session() - self.session.headers.update( - { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; " - "rv:72.0) Gecko/20100101 Firefox/72.0" - } - ) - - def _decrypt_image(self, url: str, encryption_hex: str) -> bytearray: - resp = self.session.get(url) - data = bytearray(resp.content) - key = bytes.fromhex(encryption_hex) - a = len(key) - for s in range(len(data)): - data[s] ^= key[s % a] - return data - - @lru_cache(None) - def _load_pages(self, chapter_id: Union[str, int]) -> MangaViewer: - resp = self.session.get( - f"{self._api_url}/api/manga_viewer", - params={ - "chapter_id": chapter_id, - "split": "yes" if self.split else "no", - "img_quality": self.quality, - }, - ) - return Response.FromString(resp.content).success.manga_viewer - - @lru_cache(None) - def _get_title_details(self, title_id: Union[str, int]) -> TitleDetailView: - resp = self.session.get( - f"{self._api_url}/api/title_detailV3", params={"title_id": title_id} - ) - return Response.FromString(resp.content).success.title_detail_view - - def _normalize_ids( - self, - title_ids: Collection[int], - chapter_ids: Collection[int], - min_chapter: int, - max_chapter: int, - last_chapter: bool = False, - ) -> MangaList: - # mloader allows you to mix chapters and titles(collections of chapters) - # This method tries to merge them while trying to avoid unnecessary - # http requests - if not any((title_ids, chapter_ids)): - raise ValueError("Expected at least one title or chapter id") - title_ids = set(title_ids or []) - chapter_ids = set(chapter_ids or []) - mangas = {} - chapter_meta = namedtuple("ChapterMeta", "id name") - for cid in chapter_ids: - viewer = self._load_pages(cid) - title_id = viewer.title_id - # Fetching details for this chapter also downloads all other - # visible chapters for the same title. - if title_id in title_ids: - title_ids.remove(title_id) - mangas.setdefault(title_id, []).extend( - chapter_meta(c.chapter_id, c.name) for c in viewer.chapters - ) - else: - mangas.setdefault(title_id, []).append( - chapter_meta(viewer.chapter_id, viewer.chapter_name) - ) - - for tid in title_ids: - details = self._get_title_details(tid) - mangas[tid] = [ - chapter_meta(chapter.chapter_id, chapter.name) - # Skipping mid_chapter_list, since it contains unavailable chapters - for chapter in chain.from_iterable( - chain(group.first_chapter_list, group.last_chapter_list) - for group in details.chapter_list_group - ) - ] - - for tid in mangas: - if last_chapter: - chapters = mangas[tid][-1:] - else: - chapters = [ - c - for c in mangas[tid] - if min_chapter - <= (chapter_name_to_int(c.name) or 0) - <= max_chapter - ] - - mangas[tid] = set(c.id for c in chapters) - - return mangas - - def _download(self, manga_list: MangaList): - manga_num = len(manga_list) - for title_index, (title_id, chapters) in enumerate( - manga_list.items(), 1 - ): - title = self._get_title_details(title_id).title - - title_name = title.name - log.info(f"{title_index}/{manga_num}) Manga: {title_name}") - log.info(" Author: %s", title.author) - - chapter_num = len(chapters) - for chapter_index, chapter_id in enumerate(sorted(chapters), 1): - viewer = self._load_pages(chapter_id) - chapter = viewer.pages[-1].last_page.current_chapter - next_chapter = viewer.pages[-1].last_page.next_chapter - next_chapter = ( - next_chapter if next_chapter.chapter_id != 0 else None - ) - chapter_name = viewer.chapter_name - log.info( - f" {chapter_index}/{chapter_num}) " - f"Chapter {chapter_name}: {chapter.sub_title}" - ) - exporter = self.exporter( - title=title, chapter=chapter, next_chapter=next_chapter - ) - pages = [ - p.manga_page for p in viewer.pages if p.manga_page.image_url - ] - - with click.progressbar( - pages, label=chapter_name, show_pos=True - ) as pbar: - page_counter = count() - for page_index, page in zip(page_counter, pbar): - if PageType(page.type) == PageType.double: - page_index = range(page_index, next(page_counter)) - if not exporter.skip_image(page_index): - # Todo use asyncio + async requests 3 - image_blob = self._decrypt_image( - page.image_url, page.encryption_key - ) - exporter.add_image(image_blob, page_index) - - exporter.close() - - def download( - self, - *, - title_ids: Optional[Collection[int]] = None, - chapter_ids: Optional[Collection[int]] = None, - min_chapter: int, - max_chapter: int, - last_chapter: bool = False, - ): - manga_list = self._normalize_ids( - title_ids, chapter_ids, min_chapter, max_chapter, last_chapter - ) - self._download(manga_list) diff --git a/mloader/manga_loader/__init__.py b/mloader/manga_loader/__init__.py new file mode 100644 index 0000000..861b909 --- /dev/null +++ b/mloader/manga_loader/__init__.py @@ -0,0 +1 @@ +"""Loader facade and concrete download runtime services.""" diff --git a/mloader/manga_loader/chapter_download.py b/mloader/manga_loader/chapter_download.py new file mode 100644 index 0000000..970a736 --- /dev/null +++ b/mloader/manga_loader/chapter_download.py @@ -0,0 +1,84 @@ +"""Single-chapter download orchestration service.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Collection +from dataclasses import replace + +from mloader.domain.manga import MangaPage, MangaViewer, Title +from mloader.errors import SubscriptionRequiredError +from mloader.manga_loader.manifest import TitleDownloadManifestLike +from mloader.types import ExporterFactoryLike, ExporterLike + +log = logging.getLogger(__name__) + + +class ChapterDownloader: + """Coordinate one chapter viewer payload through export and manifest tracking.""" + + @staticmethod + def process_chapter( + *, + viewer: MangaViewer, + title: Title, + chapter_index: int, + total_chapters: int, + chapter_id: int, + output_format: str, + manifest: TitleDownloadManifestLike | None, + exporter_factory: ExporterFactoryLike, + process_pages: Callable[[Collection[MangaPage], str, ExporterLike], None], + prepare_filename: Callable[[str], str], + ) -> None: + """Export one loaded chapter and update manifest state.""" + last_page = viewer.last_page + if last_page is None: + raise SubscriptionRequiredError( + "A MAX subscription is required to download this chapter. " + "The repository default/free-tier API key can only access free chapters; " + "provide subscription-capable auth settings for full-catalog downloads." + ) + + current_chapter = last_page.current_chapter + next_chapter = last_page.next_chapter + sanitized_chapter = replace( + current_chapter, + sub_title=prepare_filename(current_chapter.sub_title), + ) + + log.info( + f" {chapter_index}/{total_chapters}) Chapter " + f"{viewer.chapter_name}: {sanitized_chapter.sub_title}" + ) + if manifest is not None: + manifest.mark_started( + chapter_id, + chapter_name=viewer.chapter_name, + sub_title=sanitized_chapter.sub_title, + output_format=output_format, + ) + + exporter = exporter_factory( + title=title, + chapter=sanitized_chapter, + next_chapter=next_chapter, + ) + pages = viewer.downloadable_pages + if not pages: + raise RuntimeError( + f"MangaPlus API returned no downloadable pages for chapter {chapter_id}." + ) + try: + process_pages(pages, viewer.chapter_name, exporter) + exporter.close() + except Exception: + discard = getattr(exporter, "discard", None) + if callable(discard): + discard() + raise + + if manifest is not None: + exporter_path = getattr(exporter, "path", None) + output_path = str(exporter_path) if exporter_path is not None else None + manifest.mark_completed(chapter_id, output_path=output_path) diff --git a/mloader/manga_loader/chapter_planning.py b/mloader/manga_loader/chapter_planning.py new file mode 100644 index 0000000..e81c4da --- /dev/null +++ b/mloader/manga_loader/chapter_planning.py @@ -0,0 +1,172 @@ +"""Chapter planning and filesystem filtering services.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass +from pathlib import Path + +from mloader.domain.manga import Chapter, TitleDetail +from mloader.domain.requests import FilenameStyle +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.manga_loader.manifest import TitleDownloadManifestLike +from mloader.types import ChapterLike + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ChapterMetadata: + """Normalized chapter metadata used during planning.""" + + thumbnail_url: str + chapter_id: int + sub_title: str + + +class ChapterPlanner: + """Compute chapter extraction and download planning decisions.""" + + @staticmethod + def extract_chapter_data( + title_detail: TitleDetail, + prepare_filename: Callable[[str], str], + ) -> dict[int, ChapterMetadata]: + """Collect chapter metadata keyed by chapter ID from all chapter groups.""" + chapter_data: dict[int, ChapterMetadata] = {} + for chapter in title_detail.chapters: + chapter_data[chapter.chapter_id] = ChapterMetadata( + thumbnail_url=chapter.thumbnail_url, + chapter_id=chapter.chapter_id, + sub_title=prepare_filename(chapter.sub_title), + ) + return chapter_data + + @staticmethod + def find_chapter_by_id(title_detail: TitleDetail, chapter_id: int) -> Chapter | None: + """Find and return a chapter object by ``chapter_id`` if available.""" + return title_detail.find_chapter(chapter_id) + + @staticmethod + def build_expected_filename( + title_name: str, + chapter_obj: ChapterLike, + sub_title: str, + title_language: int = 0, + *, + filename_style: FilenameStyle = "legacy", + ) -> str: + """Build normalized filename stem expected for chapter-level outputs.""" + return FilenamePolicy.build_expected_filename( + title_name, + chapter_obj, + sub_title, + title_language, + filename_style=filename_style, + ) + + @staticmethod + def build_expected_filename_with_style( + title_name: str, + chapter_obj: ChapterLike, + sub_title: str, + title_language: int, + filename_style: FilenameStyle, + ) -> str: + """Build normalized chapter stems for a requested filename style.""" + return FilenamePolicy.build_expected_filename( + title_name, + chapter_obj, + sub_title, + title_language, + filename_style=filename_style, + ) + + @staticmethod + def filter_chapters_to_download( + chapter_data: Mapping[int, ChapterMetadata], + title_detail: TitleDetail, + existing_files: Collection[str], + requested_chapter_ids: Collection[int], + filename_style: FilenameStyle = "legacy", + ) -> list[int]: + """Return requested chapter IDs that do not yet exist on disk.""" + chapters_to_download: list[int] = [] + for _chapter_id, metadata in chapter_data.items(): + chapter_obj = ChapterPlanner.find_chapter_by_id(title_detail, metadata.chapter_id) + if chapter_obj is None: + # Keep warning for visibility when upstream chapter metadata is inconsistent. + log.warning("Chapter id %s not found in title dump; skipping", metadata.chapter_id) + continue + expected_filename = ChapterPlanner.build_expected_filename( + FilenamePolicy.title_directory_name(title_detail.title.name), + chapter_obj, + metadata.sub_title, + filename_style=filename_style, + title_language=title_detail.title.language, + ) + if expected_filename not in existing_files: + chapters_to_download.append(metadata.chapter_id) + return [ + chapter_id for chapter_id in chapters_to_download if chapter_id in requested_chapter_ids + ] + + +class DownloadPlanner: + """Compute filesystem and manifest decisions for title downloads.""" + + @staticmethod + def chapter_output_extension(output_format: str) -> str | None: + """Return chapter-level output extension, or ``None`` for raw image mode.""" + if output_format in {"pdf", "cbz"}: + return output_format + return None + + @staticmethod + def get_existing_files(export_path: Path, *, output_format: str) -> list[str]: + """Return existing chapter stems for single-file output formats.""" + if not export_path.exists(): + return [] + + extension = DownloadPlanner.chapter_output_extension(output_format) + if extension is None: + return [] + + existing_files = [file.stem for file in export_path.glob(f"*.{extension}")] + log.info(f" Found {len(existing_files)} existing chapter files in '{export_path}'.") + log.debug(f" Existing files: {existing_files}") + return existing_files + + @staticmethod + def filter_chapters_to_download( + chapter_data: Mapping[int, ChapterMetadata], + title_detail: TitleDetail, + existing_files: Collection[str], + requested_chapter_ids: Collection[int], + filename_style: FilenameStyle = "legacy", + ) -> list[int]: + """Return chapter IDs that are requested and not already exported.""" + return ChapterPlanner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files, + requested_chapter_ids, + filename_style=filename_style, + ) + + @staticmethod + def exclude_manifest_completed_chapters( + chapter_ids: Collection[int], + manifest: TitleDownloadManifestLike, + ) -> tuple[list[int], int]: + """Exclude chapter IDs already marked completed in the title manifest.""" + pending = [ + chapter_id for chapter_id in chapter_ids if not manifest.is_completed(chapter_id) + ] + skipped_count = len(chapter_ids) - len(pending) + if skipped_count: + log.info( + f" Skipping {skipped_count} chapter(s) already marked completed in manifest." + ) + return pending, skipped_count diff --git a/mloader/manga_loader/decryption.py b/mloader/manga_loader/decryption.py new file mode 100644 index 0000000..77f5360 --- /dev/null +++ b/mloader/manga_loader/decryption.py @@ -0,0 +1,20 @@ +"""Image decryption helpers used for encrypted page payloads.""" + +from __future__ import annotations + + +def _convert_hex_to_bytes(hex_str: str) -> bytes: + """ + Convert a hexadecimal string to bytes. + """ + return bytes.fromhex(hex_str) + + +def _xor_decrypt(data: bytearray, key: bytes) -> bytearray: + """ + Decrypt data using XOR with a repeating key. + """ + key_length = len(key) + for index in range(len(data)): + data[index] ^= key[index % key_length] + return data diff --git a/mloader/manga_loader/download_execution.py b/mloader/manga_loader/download_execution.py new file mode 100644 index 0000000..fc51a03 --- /dev/null +++ b/mloader/manga_loader/download_execution.py @@ -0,0 +1,327 @@ +"""Concrete download execution service for planned MangaPlus downloads.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from mloader.domain.manga import MangaPage, MangaViewer, TitleDetail +from mloader.domain.planning import ( + DownloadPlan, + TitleDownloadPlan, + title_detail_with_selected_chapters, +) +from mloader.domain.requests import CoverFormat, DownloadSummary, FilenameStyle +from mloader.errors import DownloadInterruptedError +from mloader.manga_loader.chapter_planning import ChapterMetadata +from mloader.manga_loader.download_services import DownloadServices +from mloader.manga_loader.manifest import TitleDownloadManifest, TitleDownloadManifestLike +from mloader.manga_loader.run_report import RunReport +from mloader.manga_loader.title_download import ( + ManifestFactory, + TitleDownloadContext, + TitleProcessingOptions, +) +from mloader.types import ExporterFactoryLike, ExporterLike, SessionLike + +log = logging.getLogger(__name__) + +PrepareDownloadPlan = Callable[ + [ + Collection[int] | None, + Collection[int] | None, + Collection[int] | None, + int, + int, + bool, + ], + DownloadPlan, +] +LoadPages = Callable[[str | int], MangaViewer] +ClearTitleCaches = Callable[[int, Collection[int] | None], None] + + +@dataclass(frozen=True, slots=True) +class DownloadExecutionContext: + """Runtime collaborators and options for one configured downloader.""" + + destination: str + output_format: Literal["raw", "cbz", "pdf"] + exporter: ExporterFactoryLike + session: SessionLike + request_timeout: tuple[float, float] + cover: bool + meta: bool + resume: bool + manifest_reset: bool + filename_style: FilenameStyle + rename_existing_filenames: bool + cover_format: CoverFormat + services: DownloadServices + prepare_download_plan: PrepareDownloadPlan + load_pages: LoadPages + clear_api_caches_for_run: Callable[[], None] + clear_api_caches_for_title: ClearTitleCaches + manifest_factory: ManifestFactory = TitleDownloadManifest + + +class DownloadExecutionService: + """Execute title and chapter download plans with composed runtime services.""" + + def __init__(self, context: DownloadExecutionContext) -> None: + """Store configured collaborators for download execution.""" + self.context = context + + def download( + self, + *, + title_ids: Collection[int] | None = None, + chapter_numbers: Collection[int] | None = None, + chapter_ids: Collection[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary: + """Start a download run using already validated filters.""" + report = RunReport() + self._clear_api_caches_for_run() + try: + download_plan = self._prepare_download_plan( + title_ids, + chapter_numbers, + chapter_ids, + min_chapter, + max_chapter, + last_chapter, + ) + self._download(download_plan, report) + except KeyboardInterrupt as interrupted: + raise DownloadInterruptedError(report.as_summary()) from interrupted + finally: + self._clear_api_caches_for_run() + return report.as_summary() + + def _prepare_download_plan( + self, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + ) -> DownloadPlan: + """Resolve title/chapter filters into a concrete domain download plan.""" + return self.context.prepare_download_plan( + title_ids, + chapter_numbers, + chapter_ids, + min_chapter, + max_chapter, + last_chapter, + ) + + def _download( + self, + download_plan: DownloadPlan, + report: RunReport, + ) -> None: + """Iterate through normalized titles and process them one by one.""" + total_titles = download_plan.title_count + for title_index, title_plan in enumerate(download_plan.title_plans, 1): + self._process_title(title_index, total_titles, title_plan, report=report) + + def _process_title( + self, + title_index: int, + total_titles: int, + title_plan: TitleDownloadPlan, + *, + report: RunReport, + ) -> None: + """Download and export all selected chapters for one title.""" + services = self.context.services + services.title_downloader.process_title( + title_index=title_index, + total_titles=total_titles, + title_plan=title_plan, + report=report, + context=TitleDownloadContext( + options=TitleProcessingOptions( + destination=self.context.destination, + cover=self.context.cover, + meta=self.context.meta, + resume=self.context.resume, + manifest_reset=self.context.manifest_reset, + output_format=self.context.output_format, + filename_style=self.context.filename_style, + rename_existing_filenames=self.context.rename_existing_filenames, + ), + manifest_tracker=services.manifest_tracker, + manifest_factory=self.context.manifest_factory, + dump_title_cover=self._dump_title_cover, + title_detail_with_selected_chapters=title_detail_with_selected_chapters, + extract_chapter_data=self._extract_chapter_data, + dump_title_metadata=self._dump_title_metadata, + get_existing_files=self._get_existing_files, + filter_chapters_to_download=self._filter_chapters_to_download, + exclude_manifest_completed_chapters=self._exclude_manifest_completed_chapters, + process_chapter=self._process_chapter, + clear_api_caches_for_title=self._clear_api_caches_for_title, + ), + ) + + def _process_chapter( + self, + title_detail: TitleDetail, + chapter_index: int, + total_chapters: int, + chapter_id: int, + *, + manifest: TitleDownloadManifestLike | None = None, + ) -> None: + """Download and export a single chapter.""" + viewer = self._load_pages(chapter_id) + self.context.services.chapter_downloader.process_chapter( + viewer=viewer, + title=title_detail.title, + chapter_index=chapter_index, + total_chapters=total_chapters, + chapter_id=chapter_id, + output_format=self.context.output_format, + manifest=manifest, + exporter_factory=self.context.exporter, + process_pages=self._process_chapter_pages, + prepare_filename=self._prepare_filename, + ) + + def _load_pages(self, chapter_id: str | int) -> MangaViewer: + """Load chapter viewer data through the configured gateway.""" + return self.context.load_pages(chapter_id) + + def _process_chapter_pages( + self, + pages: Collection[MangaPage], + chapter_name: str, + exporter: ExporterLike, + ) -> None: + """Download all chapter pages and pass them to the exporter.""" + page_image_service = self.context.services.page_image_service + + def download_url(url: str) -> bytes: + return page_image_service.download_image( + self.context.session, + self.context.request_timeout, + url, + ) + + def decrypt_url(url: str, encryption_hex: str) -> bytearray: + return page_image_service.decrypt_image( + self.context.session, + self.context.request_timeout, + url, + encryption_hex, + ) + + self.context.services.page_export_service.export_pages( + pages, + chapter_name, + exporter, + fetch_page_image=lambda page: page_image_service.fetch_page_image( + page, + download_image=download_url, + decrypt_image=decrypt_url, + ), + ) + + def _dump_title_metadata( + self, + title_detail: TitleDetail, + chapter_data: Mapping[int, ChapterMetadata], + export_dir: str | Path, + ) -> None: + """Write title-level metadata JSON into ``export_dir``.""" + self.context.services.metadata_exporter.dump_title_metadata( + title_detail, + chapter_data, + export_dir, + ) + log.info(f" Metadata for title '{title_detail.title.name}' exported") + + def _dump_title_cover( + self, + title_detail: TitleDetail, + export_dir: str | Path, + ) -> None: + """Download and store one title cover image using the selected cover format.""" + page_image_service = self.context.services.page_image_service + self.context.services.cover_exporter.dump_title_cover( + title_detail, + export_dir, + cover_format=self.context.cover_format, + download_image=lambda url: page_image_service.download_image( + self.context.session, + self.context.request_timeout, + url, + ), + ) + + def _extract_chapter_data(self, title_detail: TitleDetail) -> dict[int, ChapterMetadata]: + """Collect chapter metadata from all chapter groups into one mapping.""" + return self.context.services.chapter_planner.extract_chapter_data( + title_detail, + self._prepare_filename, + ) + + def _get_existing_files(self, export_path: Path) -> list[str]: + """Return existing chapter stems for single-file output formats.""" + return self.context.services.download_planner.get_existing_files( + export_path, + output_format=self.context.output_format, + ) + + def _filter_chapters_to_download( + self, + chapter_data: Mapping[int, ChapterMetadata], + title_detail: TitleDetail, + existing_files: Collection[str], + requested_chapter_ids: Collection[int], + filename_style: FilenameStyle, + ) -> list[int]: + """Return chapter IDs that are requested and not already exported.""" + return self.context.services.download_planner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files, + requested_chapter_ids, + filename_style=filename_style, + ) + + def _exclude_manifest_completed_chapters( + self, + chapter_ids: Collection[int], + manifest: TitleDownloadManifestLike, + ) -> tuple[list[int], int]: + """Exclude chapter IDs already marked completed in the title manifest.""" + return self.context.services.download_planner.exclude_manifest_completed_chapters( + chapter_ids, + manifest, + ) + + def _prepare_filename(self, text: str) -> str: + """Fix common encoding glitches and sanitize text for filesystem use.""" + return self.context.services.filename_policy.prepare_filename(text) + + def _clear_api_caches_for_run(self) -> None: + """Clear all cached gateway payloads for a download run.""" + self.context.clear_api_caches_for_run() + + def _clear_api_caches_for_title( + self, + title_id: int, + chapter_ids: Collection[int] | None, + ) -> None: + """Clear title-scoped gateway cache entries after title processing.""" + self.context.clear_api_caches_for_title(title_id, chapter_ids) diff --git a/mloader/manga_loader/download_services.py b/mloader/manga_loader/download_services.py new file mode 100644 index 0000000..9040afa --- /dev/null +++ b/mloader/manga_loader/download_services.py @@ -0,0 +1,54 @@ +"""Explicitly composed runtime services used by downloader orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mloader.manga_loader.chapter_download import ChapterDownloader +from mloader.manga_loader.chapter_planning import ( + ChapterPlanner, + DownloadPlanner, +) +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.manga_loader.manifest_tracking import ManifestTracker +from mloader.manga_loader.page_export import PageExportService, PageImageService +from mloader.manga_loader.title_assets import ( + CoverExporter, + MetadataExporter, + MetadataWriter, +) +from mloader.manga_loader.title_download import TitleDownloader + + +@dataclass(frozen=True, slots=True) +class DownloadServices: + """Container for runtime collaborators used by download orchestration.""" + + chapter_downloader: type[ChapterDownloader] + chapter_planner: type[ChapterPlanner] + metadata_writer: type[MetadataWriter] + metadata_exporter: type[MetadataExporter] + cover_exporter: type[CoverExporter] + download_planner: type[DownloadPlanner] + filename_policy: type[FilenamePolicy] + manifest_tracker: type[ManifestTracker] + page_export_service: type[PageExportService] + page_image_service: type[PageImageService] + title_downloader: type[TitleDownloader] + + @staticmethod + def defaults() -> "DownloadServices": + """Return default concrete service bindings.""" + return DownloadServices( + chapter_downloader=ChapterDownloader, + chapter_planner=ChapterPlanner, + metadata_writer=MetadataWriter, + metadata_exporter=MetadataExporter, + cover_exporter=CoverExporter, + download_planner=DownloadPlanner, + filename_policy=FilenamePolicy, + manifest_tracker=ManifestTracker, + page_export_service=PageExportService, + page_image_service=PageImageService, + title_downloader=TitleDownloader, + ) diff --git a/mloader/manga_loader/filename_policy.py b/mloader/manga_loader/filename_policy.py new file mode 100644 index 0000000..f7fbcd6 --- /dev/null +++ b/mloader/manga_loader/filename_policy.py @@ -0,0 +1,77 @@ +"""Filesystem naming policy for title and chapter outputs.""" + +from __future__ import annotations + +import logging + +from mloader.domain.requests import FilenameStyle +from mloader.types import ChapterLike +from mloader.utils import escape_path +from mloader.constants import Language + +log = logging.getLogger(__name__) + + +def _format_language_tag(language: int) -> str: + """Build a stable language tag for chapter names.""" + if language == 8: # Legacy Vietnamese code observed in older payloads. + return " [VIETNAMESE]" + + try: + parsed_language = Language(language) + except ValueError: + return f" [LANG-{language}]" + + if parsed_language == Language.ENGLISH: + return "" + + return f" [{parsed_language.name}]" + + +class FilenamePolicy: + """Centralize filename normalization for downloader outputs.""" + + @staticmethod + def format_language_tag(language: int) -> str: + """Build a stable language tag for chapter names.""" + return _format_language_tag(language) + + @staticmethod + def prepare_filename(text: str) -> str: + """Fix common encoding glitches and sanitize text for filesystem use.""" + fixed_text = text + try: + fixed_text = text.encode("latin1").decode("utf8") + except UnicodeEncodeError, UnicodeDecodeError: + log.warning(f" Encoding fix skipped for: {text}") + return escape_path(fixed_text) + + @staticmethod + def title_directory_name(title_name: str) -> str: + """Return the title directory name used for all per-title outputs.""" + return FilenamePolicy.prepare_filename(title_name).title() + + @staticmethod + def build_expected_filename( + title_name: str, + chapter_obj: ChapterLike, + sub_title: str, + title_language: int = 0, + *, + filename_style: FilenameStyle = "legacy", + ) -> str: + """Build normalized filename stem expected for chapter-level outputs.""" + _ = title_language + _ = filename_style + sanitized_title = FilenamePolicy.prepare_filename(title_name) + sanitized_chapter_name = FilenamePolicy.prepare_filename( + chapter_obj.name.lstrip("#").strip() + ) + sanitized_sub_title = FilenamePolicy.prepare_filename(sub_title) + + chapter_prefix = ( + f"{sanitized_title}{_format_language_tag(title_language)} - {sanitized_chapter_name}" + if filename_style == "new" + else f"{sanitized_title} - {sanitized_chapter_name}" + ) + return f"{chapter_prefix} - {sanitized_sub_title}" diff --git a/mloader/manga_loader/init.py b/mloader/manga_loader/init.py new file mode 100644 index 0000000..c979942 --- /dev/null +++ b/mloader/manga_loader/init.py @@ -0,0 +1,108 @@ +"""Compose loader runtime services into the concrete ``MangaLoader`` facade.""" + +from __future__ import annotations + +from typing import Literal + +from mloader.domain.requests import CoverFormat, DownloadSummary, FilenameStyle +from mloader.infrastructure.mangaplus.settings import ( + DEFAULT_API_BASE_URL, + DEFAULT_REQUEST_TIMEOUT, + DEFAULT_RETRIES, +) +from mloader.types import ExporterFactoryLike, PayloadCaptureLike, SessionLike + +from .download_services import DownloadServices +from .runner import DownloadRunner + + +class MangaLoader: + """Facade object exposing the current programmatic download API.""" + + def __init__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool = False, + destination: str = "mloader_downloads", + output_format: Literal["raw", "cbz", "pdf"] = "cbz", + session: SessionLike | None = None, + api_url: str = DEFAULT_API_BASE_URL, + request_timeout: tuple[float, float] = DEFAULT_REQUEST_TIMEOUT, + retries: int = DEFAULT_RETRIES, + capture_api_dir: str | None = None, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + resume: bool = True, + manifest_reset: bool = False, + services: DownloadServices | None = None, + cover_format: CoverFormat = "png", + ) -> None: + """Initialize the composed runtime for the current public constructor surface.""" + self._runtime = DownloadRunner( + exporter=exporter, + quality=quality, + split=split, + meta=meta, + cover=cover, + cover_format=cover_format, + destination=destination, + output_format=output_format, + session=session, + api_url=api_url, + request_timeout=request_timeout, + retries=retries, + capture_api_dir=capture_api_dir, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + resume=resume, + manifest_reset=manifest_reset, + services=services or DownloadServices.defaults(), + ) + + @property + def session(self) -> SessionLike: + """Expose active HTTP session for tests and runtime introspection.""" + return self._runtime.session + + @property + def destination(self) -> str: + """Expose configured destination directory.""" + return self._runtime.destination + + @property + def output_format(self) -> Literal["raw", "cbz", "pdf"]: + """Expose configured chapter output format.""" + return self._runtime.output_format + + @property + def request_timeout(self) -> tuple[float, float]: + """Expose configured request timeout tuple.""" + return self._runtime.request_timeout + + @property + def payload_capture(self) -> PayloadCaptureLike | None: + """Expose payload capture backend when capture mode is enabled.""" + return self._runtime.payload_capture + + def download( + self, + *, + title_ids: set[int] | frozenset[int] | None = None, + chapter_numbers: set[int] | frozenset[int] | None = None, + chapter_ids: set[int] | frozenset[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary: + """Delegate download orchestration to the composed runtime.""" + return self._runtime.download( + title_ids=title_ids, + chapter_numbers=chapter_numbers, + chapter_ids=chapter_ids, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + ) diff --git a/mloader/manga_loader/manifest.py b/mloader/manga_loader/manifest.py new file mode 100644 index 0000000..809b5a7 --- /dev/null +++ b/mloader/manga_loader/manifest.py @@ -0,0 +1,275 @@ +"""Persistent chapter download manifest used for resumable runs.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from tempfile import NamedTemporaryFile +from collections.abc import Callable +from typing import Any, Protocol + +from filelock import FileLock + +MANIFEST_FILENAME = ".mloader-manifest.json" +MANIFEST_SCHEMA = "mloader.title_download_manifest" +MANIFEST_VERSION = 2 + +type ManifestEntry = dict[str, Any] +type ManifestChapters = dict[str, ManifestEntry] +type ManifestPayload = dict[str, Any] + + +class TitleDownloadManifestLike(Protocol): + """Minimal manifest contract used by runtime services.""" + + def reset(self) -> None: + """Reset manifest state.""" + + def flush(self) -> None: + """Persist pending manifest state.""" + + def is_completed(self, chapter_id: int) -> bool: + """Return whether ``chapter_id`` is completed.""" + + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + """Mark a chapter as started.""" + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + """Mark a chapter as completed.""" + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + """Mark a chapter as failed.""" + + +def _utc_timestamp() -> str: + """Return a stable UTC timestamp string for manifest updates.""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _coerce_chapter_entries(raw_chapters: object) -> ManifestChapters: + """Return chapter-entry mapping containing only dict chapter payload values.""" + if not isinstance(raw_chapters, dict): + return {} + entries: ManifestChapters = {} + for chapter_id, entry in raw_chapters.items(): + if isinstance(entry, dict): + entries[str(chapter_id)] = {str(key): value for key, value in entry.items()} + return entries + + +def _migrate_v0_to_v1(payload: ManifestPayload) -> ManifestPayload: + """Migrate unversioned payloads into version-1 structure.""" + chapters = _coerce_chapter_entries(payload.get("chapters")) + if not chapters: + chapters = _coerce_chapter_entries(payload) + return { + "version": 1, + "chapters": chapters, + } + + +def _migrate_v1_to_v2(payload: ManifestPayload) -> ManifestPayload: + """Migrate version-1 payloads by adding explicit schema metadata.""" + return { + "version": 2, + "schema": MANIFEST_SCHEMA, + "chapters": _coerce_chapter_entries(payload.get("chapters")), + } + + +MANIFEST_MIGRATIONS: dict[int, Callable[[ManifestPayload], ManifestPayload]] = { + 0: _migrate_v0_to_v1, + 1: _migrate_v1_to_v2, +} + + +def _normalize_payload(payload: ManifestPayload) -> tuple[ManifestChapters, bool]: + """Normalize and migrate payload to current schema, returning ``(chapters, migrated)``.""" + raw_version = payload.get("version") + version = raw_version if isinstance(raw_version, int) and raw_version >= 0 else 0 + normalized: ManifestPayload = dict(payload) + + if version > MANIFEST_VERSION: + return _coerce_chapter_entries(normalized.get("chapters")), False + + migrated = False + while version < MANIFEST_VERSION: + migrator = MANIFEST_MIGRATIONS.get(version) + if migrator is None: + return {}, False + normalized = migrator(normalized) + version += 1 + migrated = True + + return _coerce_chapter_entries(normalized.get("chapters")), migrated + + +class TitleDownloadManifest: + """Manage chapter download progress for a single title directory.""" + + def __init__( + self, + title_dir: Path, + *, + autosave: bool = True, + lock_timeout: float = 30.0, + ) -> None: + """Load an existing manifest from ``title_dir`` when available.""" + self.path = title_dir / MANIFEST_FILENAME + self.lock_path = title_dir / f"{MANIFEST_FILENAME}.lock" + self._lock = FileLock(str(self.lock_path), timeout=lock_timeout) + self._autosave = autosave + self._chapters: dict[str, dict[str, Any]] = {} + self._dirty = False + self._load() + + def _load(self) -> None: + """Load chapter status entries from disk if the manifest exists.""" + with self._lock: + self._load_unlocked() + + def _load_unlocked(self) -> None: + """Load chapter status entries without acquiring the lock.""" + if not self.path.exists(): + self._chapters = {} + self._dirty = False + return + + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except OSError, json.JSONDecodeError: + self._chapters = {} + self._dirty = False + return + + if not isinstance(payload, dict): + self._chapters = {} + self._dirty = False + return + + self._chapters, migrated = _normalize_payload(payload) + self._dirty = migrated + if migrated and self._autosave: + self._save_unlocked() + + def _save_unlocked(self) -> None: + """Persist current manifest content to disk atomically without locking.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": MANIFEST_VERSION, + "schema": MANIFEST_SCHEMA, + "chapters": self._chapters, + } + with NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=self.path.parent) as tmp: + json.dump(payload, tmp, ensure_ascii=False, indent=2, sort_keys=True) + temp_path = Path(tmp.name) + temp_path.replace(self.path) + self._dirty = False + + def save(self) -> None: + """Persist current manifest content to disk atomically.""" + with self._lock: + self._save_unlocked() + + def flush(self) -> None: + """Persist pending in-memory changes when autosave is disabled.""" + if not self._dirty: + return + with self._lock: + if not self._dirty: + return + self._save_unlocked() + + def reset(self) -> None: + """Clear manifest state and remove persisted manifest file if present.""" + with self._lock: + self._chapters = {} + self._dirty = False + if self.path.exists(): + self.path.unlink() + + def is_completed(self, chapter_id: int) -> bool: + """Return ``True`` when chapter ``chapter_id`` is marked completed.""" + entry = self._chapters.get(str(chapter_id)) + if entry is None: + return False + return entry.get("status") == "completed" + + def _mark_entry(self, chapter_id: int, *, updates: dict[str, Any]) -> None: + """Update one chapter entry and persist according to autosave mode.""" + key = str(chapter_id) + if self._autosave: + with self._lock: + self._load_unlocked() + entry = dict(self._chapters.get(key, {"chapter_id": chapter_id})) + entry.update(updates) + if self._chapters.get(key) == entry: + return + self._chapters[key] = entry + self._dirty = True + self._save_unlocked() + return + + entry = dict(self._chapters.get(key, {"chapter_id": chapter_id})) + entry.update(updates) + if self._chapters.get(key) == entry: + return + self._chapters[key] = entry + self._dirty = True + + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + """Mark chapter as in progress and persist metadata for resume tracking.""" + self._mark_entry( + chapter_id, + updates={ + "chapter_id": chapter_id, + "chapter_name": chapter_name, + "sub_title": sub_title, + "output_format": output_format, + "status": "in_progress", + "started_at": _utc_timestamp(), + "completed_at": None, + "failed_at": None, + "error": None, + }, + ) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + """Mark chapter as completed and optionally store final output path.""" + updates: dict[str, Any] = { + "chapter_id": chapter_id, + "status": "completed", + "completed_at": _utc_timestamp(), + "failed_at": None, + "error": None, + } + if output_path: + updates["output_path"] = output_path + self._mark_entry(chapter_id, updates=updates) + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + """Mark chapter as failed with an error description.""" + self._mark_entry( + chapter_id, + updates={ + "chapter_id": chapter_id, + "status": "failed", + "failed_at": _utc_timestamp(), + "error": error, + }, + ) diff --git a/mloader/manga_loader/manifest_tracking.py b/mloader/manga_loader/manifest_tracking.py new file mode 100644 index 0000000..4fdb912 --- /dev/null +++ b/mloader/manga_loader/manifest_tracking.py @@ -0,0 +1,52 @@ +"""Manifest lifecycle service for resumable title downloads.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from mloader.manga_loader.manifest import TitleDownloadManifest, TitleDownloadManifestLike + + +class ManifestTracker: + """Manage manifest lifecycle operations used during title processing.""" + + @staticmethod + def prepare_manifest( + export_path: Path, + *, + resume: bool, + manifest_reset: bool, + manifest_factory: Callable[..., TitleDownloadManifestLike] = TitleDownloadManifest, + ) -> TitleDownloadManifestLike | None: + """Create and optionally reset a title manifest when manifest behavior is enabled.""" + if not resume and not manifest_reset: + return None + manifest = manifest_factory(export_path, autosave=False) + if manifest_reset: + manifest.reset() + return manifest + + @staticmethod + def mark_failed( + manifest: TitleDownloadManifestLike | None, + *, + resume: bool, + chapter_id: int, + error: str, + ) -> None: + """Mark a chapter failed when resumable manifest tracking is active.""" + if not resume or manifest is None: + return + manifest.mark_failed(chapter_id, error=error) + manifest.flush() + + @staticmethod + def flush( + manifest: TitleDownloadManifestLike | None, + *, + resume: bool, + ) -> None: + """Flush pending manifest writes when resumable manifest tracking is active.""" + if resume and manifest is not None: + manifest.flush() diff --git a/mloader/manga_loader/page_export.py b/mloader/manga_loader/page_export.py new file mode 100644 index 0000000..2c4511a --- /dev/null +++ b/mloader/manga_loader/page_export.py @@ -0,0 +1,99 @@ +"""Page image fetching and export services.""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Iterator +from itertools import count + +import click + +from mloader.constants import PageType +from mloader.domain.manga import MangaPage +from mloader.manga_loader.decryption import _convert_hex_to_bytes, _xor_decrypt +from mloader.types import ExporterLike, SessionLike + + +class PageImageService: + """Download and decrypt chapter image payloads.""" + + @staticmethod + def download_image( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + ) -> bytes: + """Download one image blob from ``url`` using configured session settings.""" + response = session.get(url, timeout=request_timeout) + response.raise_for_status() + return response.content + + @staticmethod + def fetch_encrypted_data( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + ) -> bytearray: + """Download encrypted image bytes from the source URL.""" + response = session.get(url, timeout=request_timeout) + response.raise_for_status() + return bytearray(response.content) + + @staticmethod + def decrypt_image( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + encryption_hex: str, + ) -> bytearray: + """Download and decrypt one encrypted image payload.""" + encrypted_data = PageImageService.fetch_encrypted_data(session, request_timeout, url) + encryption_key = _convert_hex_to_bytes(encryption_hex) + return _xor_decrypt(encrypted_data, encryption_key) + + @staticmethod + def fetch_page_image( + page: MangaPage, + *, + download_image: Callable[[str], bytes], + decrypt_image: Callable[[str, str], bytearray], + ) -> bytes: + """Return raw or decrypted page bytes depending on encryption key presence.""" + encryption_key = str(getattr(page, "encryption_key", "")) + if encryption_key: + return bytes(decrypt_image(page.image_url, encryption_key)) + return download_image(page.image_url) + + +class PageExportService: + """Export chapter pages with stable page-index handling.""" + + @staticmethod + def _double_page_index(page_index: int, page_counter: Iterator[int]) -> range: + """Build DOUBLE-page index marker with inclusive ``stop`` naming semantics.""" + paired_page_index = next(page_counter) + return range(page_index, paired_page_index) + + @staticmethod + def export_pages( + pages: Collection[MangaPage], + chapter_name: str, + exporter: ExporterLike, + *, + fetch_page_image: Callable[[MangaPage], bytes], + ) -> None: + """Stream chapter pages through exporter with DOUBLE-page index mapping.""" + with click.progressbar(pages, label=chapter_name, show_pos=True) as progress_bar: + page_counter = count() + for page_index, page in zip(page_counter, progress_bar): + output_index: int | range = page_index + if PageType(page.page_type) == PageType.DOUBLE: + output_index = PageExportService._double_page_index( + page_index, + page_counter, + ) + + if exporter.skip_image(output_index): + continue + + image_blob = fetch_page_image(page) + exporter.add_image(image_blob, output_index) diff --git a/mloader/manga_loader/run_report.py b/mloader/manga_loader/run_report.py new file mode 100644 index 0000000..8ccda47 --- /dev/null +++ b/mloader/manga_loader/run_report.py @@ -0,0 +1,39 @@ +"""Run-level download reporting helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from mloader.domain.requests import DownloadSummary + + +@dataclass(slots=True) +class RunReport: + """Accumulate run counters and expose immutable download summaries.""" + + downloaded: int = 0 + skipped_manifest: int = 0 + failed: int = 0 + failed_chapter_ids: list[int] = field(default_factory=list) + + def mark_downloaded(self) -> None: + """Increment downloaded chapter count.""" + self.downloaded += 1 + + def mark_manifest_skipped(self, skipped_count: int) -> None: + """Increment manifest-skip counter by ``skipped_count``.""" + self.skipped_manifest += skipped_count + + def mark_failed(self, chapter_id: int) -> None: + """Increment failure counters and record the failed chapter ID.""" + self.failed += 1 + self.failed_chapter_ids.append(chapter_id) + + def as_summary(self) -> DownloadSummary: + """Build immutable summary payload for CLI boundaries.""" + return DownloadSummary( + downloaded=self.downloaded, + skipped_manifest=self.skipped_manifest, + failed=self.failed, + failed_chapter_ids=tuple(self.failed_chapter_ids), + ) diff --git a/mloader/manga_loader/runner.py b/mloader/manga_loader/runner.py new file mode 100644 index 0000000..f1bde8a --- /dev/null +++ b/mloader/manga_loader/runner.py @@ -0,0 +1,158 @@ +"""Concrete download runtime behind the public ``MangaLoader`` facade.""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import Literal + +from mloader.domain.manga import MangaViewer, TitleDetail +from mloader.domain.planning import DownloadPlan, build_download_plan +from mloader.domain.requests import CoverFormat, DownloadSummary, FilenameStyle +from mloader.infrastructure.mangaplus.capture import APIPayloadCapture +from mloader.infrastructure.mangaplus.gateway import MangaPlusGateway +from mloader.manga_loader.download_execution import ( + DownloadExecutionContext, + DownloadExecutionService, +) +from mloader.manga_loader.download_services import DownloadServices +from mloader.types import ExporterFactoryLike, PayloadCaptureLike, SessionLike + + +class DownloadRunner: + """Composition root for MangaPlus gateway access and download execution.""" + + def __init__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool, + cover_format: CoverFormat, + destination: str, + output_format: Literal["raw", "cbz", "pdf"], + session: SessionLike | None, + api_url: str, + request_timeout: tuple[float, float], + retries: int, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + services: DownloadServices, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + ) -> None: + """Initialize gateway, capture, and execution service dependencies.""" + self.meta = meta + self.cover = cover + self.cover_format = cover_format + self.exporter = exporter + self.destination = destination + self.output_format = output_format + self.quality = quality + self.split = split + self.request_timeout = request_timeout + self.filename_style = filename_style + self.rename_existing_filenames = rename_existing_filenames + self.resume = resume + self.manifest_reset = manifest_reset + self.services = services + self.payload_capture: PayloadCaptureLike | None = ( + APIPayloadCapture(capture_api_dir) if capture_api_dir else None + ) + self.gateway = MangaPlusGateway( + session=session, + api_base_url=api_url, + quality=quality, + split=split, + request_timeout=request_timeout, + retries=retries, + payload_capture=self.payload_capture, + ) + self.session = self.gateway.session + self._api_url = self.gateway.api_base_url + + def download( + self, + *, + title_ids: Collection[int] | None = None, + chapter_numbers: Collection[int] | None = None, + chapter_ids: Collection[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary: + """Start a download run using already validated filters.""" + return self._execution_service().download( + title_ids=title_ids, + chapter_numbers=chapter_numbers, + chapter_ids=chapter_ids, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + ) + + def _execution_service(self) -> DownloadExecutionService: + """Build a download execution service for current runtime settings.""" + return DownloadExecutionService( + DownloadExecutionContext( + destination=self.destination, + output_format=self.output_format, + exporter=self.exporter, + session=self.session, + request_timeout=self.request_timeout, + cover=self.cover, + meta=self.meta, + resume=self.resume, + filename_style=self.filename_style, + rename_existing_filenames=self.rename_existing_filenames, + manifest_reset=self.manifest_reset, + cover_format=self.cover_format, + services=self.services, + prepare_download_plan=self._prepare_download_plan, + load_pages=self._load_pages, + clear_api_caches_for_run=self._clear_api_caches_for_run, + clear_api_caches_for_title=self._clear_api_caches_for_title, + ) + ) + + def _prepare_download_plan( + self, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + ) -> DownloadPlan: + """Resolve title/chapter filters into a concrete domain download plan.""" + return build_download_plan( + title_ids=title_ids, + chapter_numbers=chapter_numbers, + chapter_ids=chapter_ids, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + load_title_detail=lambda title_id: self._get_title_details(title_id), + load_viewer=lambda chapter_id: self._load_pages(chapter_id), + ) + + def _get_title_details(self, title_id: str | int) -> TitleDetail: + """Load title details through the MangaPlus gateway.""" + return self.gateway.get_title_details(title_id) + + def _load_pages(self, chapter_id: str | int) -> MangaViewer: + """Load chapter viewer data through the MangaPlus gateway.""" + return self.gateway.load_pages(chapter_id) + + def _clear_api_caches_for_run(self) -> None: + """Clear all cached gateway payloads for a download run.""" + self.gateway.clear_run_caches() + + def _clear_api_caches_for_title( + self, + title_id: int, + chapter_ids: Collection[int] | None, + ) -> None: + """Clear title-scoped gateway cache entries after title processing.""" + self.gateway.clear_title_caches(title_id, chapter_ids) diff --git a/mloader/manga_loader/title_assets.py b/mloader/manga_loader/title_assets.py new file mode 100644 index 0000000..ac0f6c4 --- /dev/null +++ b/mloader/manga_loader/title_assets.py @@ -0,0 +1,124 @@ +"""Title metadata and cover export services.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable, Mapping +from io import BytesIO +from pathlib import Path + +from PIL import Image + +from mloader.domain.manga import TitleDetail +from mloader.domain.requests import CoverFormat +from mloader.manga_loader.chapter_planning import ChapterMetadata +from mloader.utils import escape_path + +log = logging.getLogger(__name__) + + +class MetadataWriter: + """Write metadata outputs derived from title details.""" + + @staticmethod + def dump_title_metadata( + title_detail: TitleDetail, + chapter_data: Mapping[int, ChapterMetadata], + export_dir: str | Path, + ) -> None: + """Write title-level metadata JSON into ``export_dir``.""" + normalized_chapter_data = { + str(chapter_id): { + "thumbnail_url": metadata.thumbnail_url, + "chapter_id": metadata.chapter_id, + "sub_title": escape_path(metadata.sub_title).title(), + } + for chapter_id, metadata in sorted(chapter_data.items()) + } + export_dir_path = Path(export_dir) + export_dir_path.mkdir(parents=True, exist_ok=True) + + title_data = { + "non_appearance_info": title_detail.non_appearance_info, + "number_of_views": title_detail.number_of_views, + "overview": title_detail.overview, + "name": title_detail.title.name, + "author": title_detail.title.author, + "portrait_image_url": title_detail.title.portrait_image_url, + "chapters": normalized_chapter_data, + } + + metadata_file = export_dir_path / "title_metadata.json" + with metadata_file.open("w", encoding="utf-8") as file_obj: + json.dump(title_data, file_obj, ensure_ascii=False, indent=4) + + +class MetadataExporter: + """Coordinate title metadata export through the configured writer.""" + + @staticmethod + def dump_title_metadata( + title_detail: TitleDetail, + chapter_data: Mapping[int, ChapterMetadata], + export_dir: str | Path, + ) -> None: + """Write title-level metadata JSON into ``export_dir``.""" + MetadataWriter.dump_title_metadata(title_detail, chapter_data, export_dir) + + +class CoverExporter: + """Download and persist title cover images.""" + + @staticmethod + def resolve_cover_image_url(title_detail: TitleDetail) -> str | None: + """Resolve the best available cover URL from title-detail payload data.""" + portrait_cover_url = title_detail.title.portrait_image_url.strip() + if portrait_cover_url: + return portrait_cover_url + primary_cover_url = title_detail.title_image_url.strip() + if primary_cover_url: + return primary_cover_url + landscape_cover_url = title_detail.title.landscape_image_url.strip() + if landscape_cover_url: + return landscape_cover_url + return None + + @staticmethod + def dump_title_cover( + title_detail: TitleDetail, + export_dir: str | Path, + *, + cover_format: CoverFormat, + download_image: Callable[[str], bytes], + ) -> None: + """Download and store one title cover image using the selected cover format.""" + cover_url = CoverExporter.resolve_cover_image_url(title_detail) + if cover_url is None: + log.warning( + " Cover export skipped for '%s': no cover URL found.", + title_detail.title.name, + ) + return + + export_dir_path = Path(export_dir) + export_dir_path.mkdir(parents=True, exist_ok=True) + cover_path = export_dir_path / f"cover.{cover_format}" + if cover_path.exists(): + log.info(" Cover for title '%s' already exists.", title_detail.title.name) + return + + image_blob = download_image(cover_url) + with Image.open(BytesIO(image_blob)) as image: + if cover_format == "png": + converted = image.convert("RGBA") + converted.save(cover_path, format="PNG") + elif cover_format == "jpg": + converted = image.convert("RGB") + converted.save(cover_path, format="JPEG", quality=95) + elif cover_format == "webp": + converted = image.convert("RGBA") + converted.save(cover_path, format="WEBP", quality=90) + else: + raise ValueError(f"Unsupported cover format: {cover_format}") + log.info(" Cover for title '%s' exported.", title_detail.title.name) diff --git a/mloader/manga_loader/title_download.py b/mloader/manga_loader/title_download.py new file mode 100644 index 0000000..f560567 --- /dev/null +++ b/mloader/manga_loader/title_download.py @@ -0,0 +1,222 @@ +"""Single-title download orchestration service.""" + +from __future__ import annotations + +import logging +from contextlib import suppress +from collections.abc import Callable, Collection, Mapping +from dataclasses import dataclass +from pathlib import Path + +from mloader.domain.manga import Chapter, TitleDetail +from mloader.domain.planning import TitleDownloadPlan +from mloader.manga_loader.chapter_planning import ChapterMetadata +from mloader.domain.requests import FilenameStyle +from mloader.manga_loader.chapter_planning import ChapterPlanner +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.manga_loader.manifest import TitleDownloadManifestLike +from mloader.manga_loader.manifest_tracking import ManifestTracker +from mloader.manga_loader.run_report import RunReport + +log = logging.getLogger(__name__) + +ManifestFactory = Callable[..., TitleDownloadManifestLike] +ProcessChapter = Callable[..., None] + + +@dataclass(frozen=True, slots=True) +class TitleProcessingOptions: + """Runtime flags needed while processing one title.""" + + destination: str + cover: bool + meta: bool + resume: bool + manifest_reset: bool + filename_style: FilenameStyle + output_format: str + rename_existing_filenames: bool + + +@dataclass(frozen=True, slots=True) +class TitleDownloadContext: + """Collaborators needed to process one title download plan.""" + + options: TitleProcessingOptions + manifest_tracker: type[ManifestTracker] + manifest_factory: ManifestFactory + dump_title_cover: Callable[[TitleDetail, Path], None] + title_detail_with_selected_chapters: Callable[[TitleDetail, Collection[Chapter]], TitleDetail] + extract_chapter_data: Callable[[TitleDetail], Mapping[int, ChapterMetadata]] + dump_title_metadata: Callable[[TitleDetail, Mapping[int, ChapterMetadata], Path], None] + get_existing_files: Callable[[Path], list[str]] + filter_chapters_to_download: Callable[ + [ + Mapping[int, ChapterMetadata], + TitleDetail, + Collection[str], + Collection[int], + FilenameStyle, + ], + list[int], + ] + exclude_manifest_completed_chapters: Callable[ + [Collection[int], TitleDownloadManifestLike], tuple[list[int], int] + ] + process_chapter: ProcessChapter + clear_api_caches_for_title: Callable[[int, Collection[int]], None] + + +class TitleDownloader: + """Coordinate title-level export, manifest, and chapter-processing flow.""" + + @staticmethod + def process_title( + *, + title_index: int, + total_titles: int, + title_plan: TitleDownloadPlan, + report: RunReport, + context: TitleDownloadContext, + ) -> None: + """Download and export all selected chapters for one title.""" + manifest: TitleDownloadManifestLike | None = None + options = context.options + title_detail = title_plan.title_detail + title = title_detail.title + chapter_ids = title_plan.chapter_ids + try: + log.info(f"{title_index}/{total_titles}) Manga: {title.name}") + log.info(f" Author: {title.author}") + + export_path = Path(options.destination) / FilenamePolicy.title_directory_name( + title.name + ) + if options.cover: + try: + context.dump_title_cover(title_detail, export_path) + except Exception as error: + log.warning(" Cover export failed for '%s': %s", title.name, error) + manifest = context.manifest_tracker.prepare_manifest( + export_path, + resume=options.resume, + manifest_reset=options.manifest_reset, + manifest_factory=context.manifest_factory, + ) + + planned_title_detail = context.title_detail_with_selected_chapters( + title_detail, + title_plan.selected_chapters, + ) + chapter_data = context.extract_chapter_data(planned_title_detail) + + if options.meta: + context.dump_title_metadata(planned_title_detail, chapter_data, export_path) + + if context.options.rename_existing_filenames: + _rename_existing_filenames_to_style( + export_path=export_path, + output_format=context.options.output_format, + title_detail=planned_title_detail, + chapter_data=chapter_data, + filename_style=context.options.filename_style, + ) + + existing_files = context.get_existing_files(export_path) + chapters_to_download = context.filter_chapters_to_download( + chapter_data, + planned_title_detail, + existing_files, + chapter_ids, + context.options.filename_style, + ) + if options.resume and manifest is not None: + chapters_to_download, skipped_manifest = ( + context.exclude_manifest_completed_chapters( + chapters_to_download, + manifest, + ) + ) + report.mark_manifest_skipped(skipped_manifest) + + if not chapters_to_download: + log.info(f" All chapters for '{title.name}' are already downloaded.") + return + + total_chapters = len(chapters_to_download) + log.info(f" {total_chapters} chapter(s) to download for '{title.name}'.") + for chapter_index, chapter_id in enumerate(sorted(chapters_to_download), 1): + try: + context.process_chapter( + title_detail, + chapter_index, + total_chapters, + chapter_id, + manifest=manifest if options.resume else None, + ) + report.mark_downloaded() + except KeyboardInterrupt: + context.manifest_tracker.mark_failed( + manifest, + resume=options.resume, + chapter_id=chapter_id, + error="Interrupted by user.", + ) + report.mark_failed(chapter_id) + log.warning(" Interrupted while downloading chapter %s.", chapter_id) + raise + except Exception as error: + context.manifest_tracker.mark_failed( + manifest, + resume=options.resume, + chapter_id=chapter_id, + error=str(error), + ) + report.mark_failed(chapter_id) + log.error(" Failed chapter %s: %s", chapter_id, error) + finally: + context.manifest_tracker.flush(manifest, resume=options.resume) + context.clear_api_caches_for_title(title.title_id, chapter_ids) + + +def _rename_existing_filenames_to_style( + *, + output_format: str, + export_path: Path, + title_detail: TitleDetail, + chapter_data: Mapping[int, ChapterMetadata], + filename_style: FilenameStyle, +) -> None: + """Rename legacy filenames to requested style in the title output directory.""" + if output_format not in {"pdf", "cbz"}: + return + + title_name = FilenamePolicy.title_directory_name(title_detail.title.name) + + for metadata in chapter_data.values(): + chapter = title_detail.find_chapter(metadata.chapter_id) + if chapter is None: + continue + + legacy_stem = ChapterPlanner.build_expected_filename_with_style( + title_name, + chapter, + metadata.sub_title, + title_detail.title.language, + filename_style="legacy", + ) + target_stem = ChapterPlanner.build_expected_filename_with_style( + title_name, + chapter, + metadata.sub_title, + title_detail.title.language, + filename_style=filename_style, + ) + if legacy_stem == target_stem: + continue + + old_path = export_path / f"{legacy_stem}.{output_format}" + new_path = export_path / f"{target_stem}.{output_format}" + with suppress(FileNotFoundError, FileExistsError, OSError): + if old_path.exists() and not new_path.exists(): + old_path.replace(new_path) diff --git a/mloader/response_pb2.py b/mloader/response_pb2.py index 3133ed8..d8f2fb4 100644 --- a/mloader/response_pb2.py +++ b/mloader/response_pb2.py @@ -1,11 +1,22 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE # source: response.proto - +# Protobuf Python Version: 6.33.4 +"""Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from google.protobuf import reflection as _reflection +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 4, + '', + 'response.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -13,1749 +24,72 @@ -DESCRIPTOR = _descriptor.FileDescriptor( - name='response.proto', - package='manga', - syntax='proto3', - serialized_options=None, - create_key=_descriptor._internal_create_key, - serialized_pb=b'\n\x0eresponse.proto\x12\x05manga\"P\n\x06\x42\x61nner\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x12\n\n\x02id\x18\x03 \x01(\r\"B\n\nBannerList\x12\x14\n\x0c\x62\x61nner_title\x18\x01 \x01(\t\x12\x1e\n\x07\x62\x61nners\x18\x02 \x03(\x0b\x32\r.manga.Banner\"/\n\x10TransitionAction\x12\x0e\n\x06method\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\"\xc9\x01\n\x07\x43hapter\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x11\n\tsub_title\x18\x04 \x01(\t\x12\x15\n\rthumbnail_url\x18\x05 \x01(\t\x12\x17\n\x0fstart_timestamp\x18\x06 \x01(\r\x12\x15\n\rend_timestamp\x18\x07 \x01(\r\x12\x16\n\x0e\x61lready_viewed\x18\x08 \x01(\x08\x12\x18\n\x10is_vertical_only\x18\t \x01(\x08\"\xa8\x01\n\x0c\x43hapterGroup\x12\x17\n\x0f\x63hapter_numbers\x18\x01 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\x02 \x03(\x0b\x32\x0e.manga.Chapter\x12(\n\x10mid_chapter_list\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\x04 \x03(\x0b\x32\x0e.manga.Chapter\"\xaf\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05index\x18\x02 \x01(\r\x12\x11\n\tuser_name\x18\x03 \x01(\t\x12\x10\n\x08icon_url\x18\x04 \x01(\t\x12\x15\n\ris_my_comment\x18\x06 \x01(\x08\x12\x15\n\ralready_liked\x18\x07 \x01(\x08\x12\x17\n\x0fnumber_of_likes\x18\t \x01(\r\x12\x0c\n\x04\x62ody\x18\n \x01(\t\x12\x0f\n\x07\x63reated\x18\x0b \x01(\r\"\xfa\x03\n\rAdNetworkList\x12\x33\n\x0b\x61\x64_networks\x18\x01 \x01(\x0b\x32\x1e.manga.AdNetworkList.AdNetwork\x1a\xb3\x03\n\tAdNetwork\x12\x39\n\x08\x66\x61\x63\x65\x62ook\x18\x01 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Facebook\x12\x33\n\x05\x61\x64mob\x18\x02 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Admob\x12\x33\n\x05mopub\x18\x03 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Mopub\x12\x37\n\x07\x61\x64sense\x18\x04 \x01(\x0b\x32&.manga.AdNetworkList.AdNetwork.Adsense\x12\x39\n\x08\x61pplovin\x18\x05 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Applovin\x1a \n\x08\x46\x61\x63\x65\x62ook\x12\x14\n\x0cplacement_id\x18\x01 \x01(\t\x1a\x18\n\x05\x41\x64mob\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x18\n\x05Mopub\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1a\n\x07\x41\x64sense\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1b\n\x08\x41pplovin\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\"\xb8\x04\n\x05Popup\x12*\n\nos_default\x18\x01 \x01(\x0b\x32\x16.manga.Popup.OSDefault\x12,\n\x0b\x61pp_default\x18\x02 \x01(\x0b\x32\x17.manga.Popup.AppDefault\x12.\n\x0cmovie_reward\x18\x03 \x01(\x0b\x32\x18.manga.Popup.MovieReward\x1a?\n\x06\x42utton\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x1a\xab\x01\n\tOSDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12&\n\tok_button\x18\x03 \x01(\x0b\x32\x13.manga.Popup.Button\x12+\n\x0eneutral_button\x18\x04 \x01(\x0b\x32\x13.manga.Popup.Button\x12*\n\rcancel_button\x18\x05 \x01(\x0b\x32\x13.manga.Popup.Button\x1ag\n\nAppDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x03 \x01(\x0b\x32\x17.manga.TransitionAction\x12\x11\n\timage_url\x18\x04 \x01(\t\x1aM\n\x0bMovieReward\x12\x11\n\timage_url\x18\x01 \x01(\t\x12+\n\radvertisement\x18\x02 \x01(\x0b\x32\x14.manga.AdNetworkList\"\x95\x02\n\x08LastPage\x12\'\n\x0f\x63urrent_chapter\x18\x01 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0cnext_chapter\x18\x02 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0ctop_comments\x18\x03 \x03(\x0b\x32\x0e.manga.Comment\x12\x15\n\ris_subscribed\x18\x04 \x01(\x08\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x14\n\x0c\x63hapter_type\x18\x06 \x01(\x05\x12+\n\radvertisement\x18\x07 \x01(\x0b\x32\x14.manga.AdNetworkList\x12\"\n\x0cmovie_reward\x18\x08 \x01(\x0b\x32\x0c.manga.Popup\"c\n\tMangaPage\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\r\n\x05width\x18\x02 \x01(\r\x12\x0e\n\x06height\x18\x03 \x01(\r\x12\x0c\n\x04type\x18\x04 \x01(\x05\x12\x16\n\x0e\x65ncryption_key\x18\x05 \x01(\t\"\xa5\x01\n\x04Page\x12$\n\nmanga_page\x18\x01 \x01(\x0b\x32\x10.manga.MangaPage\x12&\n\x0b\x62\x61nner_list\x18\x02 \x01(\x0b\x32\x11.manga.BannerList\x12\"\n\tlast_page\x18\x03 \x01(\x0b\x32\x0f.manga.LastPage\x12+\n\radvertisement\x18\x04 \x01(\x0b\x32\x14.manga.AdNetworkList\" \n\x03Sns\x12\x0c\n\x04\x62ody\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\"\x84\x02\n\x0bMangaViewer\x12\x1a\n\x05pages\x18\x01 \x03(\x0b\x32\x0b.manga.Page\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12 \n\x08\x63hapters\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12\x17\n\x03sns\x18\x04 \x01(\x0b\x32\n.manga.Sns\x12\x12\n\ntitle_name\x18\x05 \x01(\t\x12\x14\n\x0c\x63hapter_name\x18\x06 \x01(\t\x12\x1a\n\x12number_of_comments\x18\x07 \x01(\r\x12\x18\n\x10is_vertical_only\x18\x08 \x01(\x08\x12\x10\n\x08title_id\x18\t \x01(\r\x12\x18\n\x10start_from_right\x18\n \x01(\x08\"\x96\x01\n\x05Title\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x1a\n\x12portrait_image_url\x18\x04 \x01(\t\x12\x1b\n\x13landscape_image_url\x18\x05 \x01(\t\x12\x12\n\nview_count\x18\x06 \x01(\r\x12\x10\n\x08language\x18\x07 \x01(\x05\"\xce\x04\n\x0fTitleDetailView\x12\x1b\n\x05title\x18\x01 \x01(\x0b\x32\x0c.manga.Title\x12\x17\n\x0ftitle_image_url\x18\x02 \x01(\t\x12\x10\n\x08overview\x18\x03 \x01(\t\x12\x1c\n\x14\x62\x61\x63kground_image_url\x18\x04 \x01(\t\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x15\n\rupdate_timing\x18\x06 \x01(\x05\x12\"\n\x1aviewing_period_description\x18\x07 \x01(\t\x12\x1b\n\x13non_appearance_info\x18\x08 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\t \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\n \x03(\x0b\x32\x0e.manga.Chapter\x12\x1e\n\x07\x62\x61nners\x18\x0b \x03(\x0b\x32\r.manga.Banner\x12,\n\x16recommended_title_list\x18\x0c \x03(\x0b\x32\x0c.manga.Title\x12\x17\n\x03sns\x18\r \x01(\x0b\x32\n.manga.Sns\x12\x19\n\x11is_simul_released\x18\x0e \x01(\x08\x12\x15\n\ris_subscribed\x18\x0f \x01(\x08\x12\x0e\n\x06rating\x18\x10 \x01(\x05\x12\x1b\n\x13\x63hapters_descending\x18\x11 \x01(\x08\x12\x17\n\x0fnumber_of_views\x18\x12 \x01(\r\x12/\n\x12\x63hapter_list_group\x18\x1c \x03(\x0b\x32\x13.manga.ChapterGroup\"l\n\rSuccessResult\x12\x31\n\x11title_detail_view\x18\x08 \x01(\x0b\x32\x16.manga.TitleDetailView\x12(\n\x0cmanga_viewer\x18\n \x01(\x0b\x32\x12.manga.MangaViewer\"1\n\x08Response\x12%\n\x07success\x18\x01 \x01(\x0b\x32\x14.manga.SuccessResultb\x06proto3' -) - - - - -_BANNER = _descriptor.Descriptor( - name='Banner', - full_name='manga.Banner', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Banner.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Banner.action', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='id', full_name='manga.Banner.id', index=2, - number=3, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=25, - serialized_end=105, -) - - -_BANNERLIST = _descriptor.Descriptor( - name='BannerList', - full_name='manga.BannerList', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='banner_title', full_name='manga.BannerList.banner_title', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banners', full_name='manga.BannerList.banners', index=1, - number=2, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=107, - serialized_end=173, -) - - -_TRANSITIONACTION = _descriptor.Descriptor( - name='TransitionAction', - full_name='manga.TransitionAction', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='method', full_name='manga.TransitionAction.method', index=0, - number=1, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='url', full_name='manga.TransitionAction.url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=175, - serialized_end=222, -) - - -_CHAPTER = _descriptor.Descriptor( - name='Chapter', - full_name='manga.Chapter', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.Chapter.title_id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_id', full_name='manga.Chapter.chapter_id', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='name', full_name='manga.Chapter.name', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sub_title', full_name='manga.Chapter.sub_title', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='thumbnail_url', full_name='manga.Chapter.thumbnail_url', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='start_timestamp', full_name='manga.Chapter.start_timestamp', index=5, - number=6, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='end_timestamp', full_name='manga.Chapter.end_timestamp', index=6, - number=7, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='already_viewed', full_name='manga.Chapter.already_viewed', index=7, - number=8, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_vertical_only', full_name='manga.Chapter.is_vertical_only', index=8, - number=9, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=225, - serialized_end=426, -) - - -_CHAPTERGROUP = _descriptor.Descriptor( - name='ChapterGroup', - full_name='manga.ChapterGroup', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='chapter_numbers', full_name='manga.ChapterGroup.chapter_numbers', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='first_chapter_list', full_name='manga.ChapterGroup.first_chapter_list', index=1, - number=2, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='mid_chapter_list', full_name='manga.ChapterGroup.mid_chapter_list', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_chapter_list', full_name='manga.ChapterGroup.last_chapter_list', index=3, - number=4, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=429, - serialized_end=597, -) - - -_COMMENT = _descriptor.Descriptor( - name='Comment', - full_name='manga.Comment', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='id', full_name='manga.Comment.id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='index', full_name='manga.Comment.index', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='user_name', full_name='manga.Comment.user_name', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='icon_url', full_name='manga.Comment.icon_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_my_comment', full_name='manga.Comment.is_my_comment', index=4, - number=6, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='already_liked', full_name='manga.Comment.already_liked', index=5, - number=7, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_likes', full_name='manga.Comment.number_of_likes', index=6, - number=9, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Comment.body', index=7, - number=10, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='created', full_name='manga.Comment.created', index=8, - number=11, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=600, - serialized_end=775, -) - - -_ADNETWORKLIST_ADNETWORK_FACEBOOK = _descriptor.Descriptor( - name='Facebook', - full_name='manga.AdNetworkList.AdNetwork.Facebook', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='placement_id', full_name='manga.AdNetworkList.AdNetwork.Facebook.placement_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1143, - serialized_end=1175, -) - -_ADNETWORKLIST_ADNETWORK_ADMOB = _descriptor.Descriptor( - name='Admob', - full_name='manga.AdNetworkList.AdNetwork.Admob', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Admob.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1177, - serialized_end=1201, -) - -_ADNETWORKLIST_ADNETWORK_MOPUB = _descriptor.Descriptor( - name='Mopub', - full_name='manga.AdNetworkList.AdNetwork.Mopub', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Mopub.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1203, - serialized_end=1227, -) - -_ADNETWORKLIST_ADNETWORK_ADSENSE = _descriptor.Descriptor( - name='Adsense', - full_name='manga.AdNetworkList.AdNetwork.Adsense', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Adsense.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1229, - serialized_end=1255, -) - -_ADNETWORKLIST_ADNETWORK_APPLOVIN = _descriptor.Descriptor( - name='Applovin', - full_name='manga.AdNetworkList.AdNetwork.Applovin', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Applovin.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1257, - serialized_end=1284, -) - -_ADNETWORKLIST_ADNETWORK = _descriptor.Descriptor( - name='AdNetwork', - full_name='manga.AdNetworkList.AdNetwork', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='facebook', full_name='manga.AdNetworkList.AdNetwork.facebook', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='admob', full_name='manga.AdNetworkList.AdNetwork.admob', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='mopub', full_name='manga.AdNetworkList.AdNetwork.mopub', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='adsense', full_name='manga.AdNetworkList.AdNetwork.adsense', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='applovin', full_name='manga.AdNetworkList.AdNetwork.applovin', index=4, - number=5, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_ADNETWORKLIST_ADNETWORK_FACEBOOK, _ADNETWORKLIST_ADNETWORK_ADMOB, _ADNETWORKLIST_ADNETWORK_MOPUB, _ADNETWORKLIST_ADNETWORK_ADSENSE, _ADNETWORKLIST_ADNETWORK_APPLOVIN, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=849, - serialized_end=1284, -) - -_ADNETWORKLIST = _descriptor.Descriptor( - name='AdNetworkList', - full_name='manga.AdNetworkList', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='ad_networks', full_name='manga.AdNetworkList.ad_networks', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_ADNETWORKLIST_ADNETWORK, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=778, - serialized_end=1284, -) - - -_POPUP_BUTTON = _descriptor.Descriptor( - name='Button', - full_name='manga.Popup.Button', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='text', full_name='manga.Popup.Button.text', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Popup.Button.action', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1434, - serialized_end=1497, -) - -_POPUP_OSDEFAULT = _descriptor.Descriptor( - name='OSDefault', - full_name='manga.Popup.OSDefault', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='subject', full_name='manga.Popup.OSDefault.subject', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Popup.OSDefault.body', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='ok_button', full_name='manga.Popup.OSDefault.ok_button', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='neutral_button', full_name='manga.Popup.OSDefault.neutral_button', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='cancel_button', full_name='manga.Popup.OSDefault.cancel_button', index=4, - number=5, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1500, - serialized_end=1671, -) - -_POPUP_APPDEFAULT = _descriptor.Descriptor( - name='AppDefault', - full_name='manga.Popup.AppDefault', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='subject', full_name='manga.Popup.AppDefault.subject', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Popup.AppDefault.body', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Popup.AppDefault.action', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Popup.AppDefault.image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1673, - serialized_end=1776, -) - -_POPUP_MOVIEREWARD = _descriptor.Descriptor( - name='MovieReward', - full_name='manga.Popup.MovieReward', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Popup.MovieReward.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.Popup.MovieReward.advertisement', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1778, - serialized_end=1855, -) - -_POPUP = _descriptor.Descriptor( - name='Popup', - full_name='manga.Popup', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='os_default', full_name='manga.Popup.os_default', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='app_default', full_name='manga.Popup.app_default', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='movie_reward', full_name='manga.Popup.movie_reward', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_POPUP_BUTTON, _POPUP_OSDEFAULT, _POPUP_APPDEFAULT, _POPUP_MOVIEREWARD, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1287, - serialized_end=1855, -) - - -_LASTPAGE = _descriptor.Descriptor( - name='LastPage', - full_name='manga.LastPage', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='current_chapter', full_name='manga.LastPage.current_chapter', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_chapter', full_name='manga.LastPage.next_chapter', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='top_comments', full_name='manga.LastPage.top_comments', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_subscribed', full_name='manga.LastPage.is_subscribed', index=3, - number=4, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_timestamp', full_name='manga.LastPage.next_timestamp', index=4, - number=5, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_type', full_name='manga.LastPage.chapter_type', index=5, - number=6, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.LastPage.advertisement', index=6, - number=7, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='movie_reward', full_name='manga.LastPage.movie_reward', index=7, - number=8, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1858, - serialized_end=2135, -) - - -_MANGAPAGE = _descriptor.Descriptor( - name='MangaPage', - full_name='manga.MangaPage', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.MangaPage.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='width', full_name='manga.MangaPage.width', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='height', full_name='manga.MangaPage.height', index=2, - number=3, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='type', full_name='manga.MangaPage.type', index=3, - number=4, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='encryption_key', full_name='manga.MangaPage.encryption_key', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2137, - serialized_end=2236, -) - - -_PAGE = _descriptor.Descriptor( - name='Page', - full_name='manga.Page', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='manga_page', full_name='manga.Page.manga_page', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banner_list', full_name='manga.Page.banner_list', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_page', full_name='manga.Page.last_page', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.Page.advertisement', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2239, - serialized_end=2404, -) - - -_SNS = _descriptor.Descriptor( - name='Sns', - full_name='manga.Sns', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='body', full_name='manga.Sns.body', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='url', full_name='manga.Sns.url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2406, - serialized_end=2438, -) - - -_MANGAVIEWER = _descriptor.Descriptor( - name='MangaViewer', - full_name='manga.MangaViewer', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='pages', full_name='manga.MangaViewer.pages', index=0, - number=1, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_id', full_name='manga.MangaViewer.chapter_id', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapters', full_name='manga.MangaViewer.chapters', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sns', full_name='manga.MangaViewer.sns', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_name', full_name='manga.MangaViewer.title_name', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_name', full_name='manga.MangaViewer.chapter_name', index=5, - number=6, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_comments', full_name='manga.MangaViewer.number_of_comments', index=6, - number=7, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_vertical_only', full_name='manga.MangaViewer.is_vertical_only', index=7, - number=8, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.MangaViewer.title_id', index=8, - number=9, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='start_from_right', full_name='manga.MangaViewer.start_from_right', index=9, - number=10, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2441, - serialized_end=2701, -) - - -_TITLE = _descriptor.Descriptor( - name='Title', - full_name='manga.Title', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.Title.title_id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='name', full_name='manga.Title.name', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='author', full_name='manga.Title.author', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='portrait_image_url', full_name='manga.Title.portrait_image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='landscape_image_url', full_name='manga.Title.landscape_image_url', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='view_count', full_name='manga.Title.view_count', index=5, - number=6, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='language', full_name='manga.Title.language', index=6, - number=7, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2704, - serialized_end=2854, -) - - -_TITLEDETAILVIEW = _descriptor.Descriptor( - name='TitleDetailView', - full_name='manga.TitleDetailView', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title', full_name='manga.TitleDetailView.title', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_image_url', full_name='manga.TitleDetailView.title_image_url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='overview', full_name='manga.TitleDetailView.overview', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='background_image_url', full_name='manga.TitleDetailView.background_image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_timestamp', full_name='manga.TitleDetailView.next_timestamp', index=4, - number=5, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='update_timing', full_name='manga.TitleDetailView.update_timing', index=5, - number=6, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='viewing_period_description', full_name='manga.TitleDetailView.viewing_period_description', index=6, - number=7, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='non_appearance_info', full_name='manga.TitleDetailView.non_appearance_info', index=7, - number=8, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='first_chapter_list', full_name='manga.TitleDetailView.first_chapter_list', index=8, - number=9, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_chapter_list', full_name='manga.TitleDetailView.last_chapter_list', index=9, - number=10, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banners', full_name='manga.TitleDetailView.banners', index=10, - number=11, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='recommended_title_list', full_name='manga.TitleDetailView.recommended_title_list', index=11, - number=12, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sns', full_name='manga.TitleDetailView.sns', index=12, - number=13, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_simul_released', full_name='manga.TitleDetailView.is_simul_released', index=13, - number=14, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_subscribed', full_name='manga.TitleDetailView.is_subscribed', index=14, - number=15, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='rating', full_name='manga.TitleDetailView.rating', index=15, - number=16, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapters_descending', full_name='manga.TitleDetailView.chapters_descending', index=16, - number=17, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_views', full_name='manga.TitleDetailView.number_of_views', index=17, - number=18, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_list_group', full_name='manga.TitleDetailView.chapter_list_group', index=18, - number=28, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2857, - serialized_end=3447, -) - - -_SUCCESSRESULT = _descriptor.Descriptor( - name='SuccessResult', - full_name='manga.SuccessResult', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_detail_view', full_name='manga.SuccessResult.title_detail_view', index=0, - number=8, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='manga_viewer', full_name='manga.SuccessResult.manga_viewer', index=1, - number=10, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=3449, - serialized_end=3557, -) - - -_RESPONSE = _descriptor.Descriptor( - name='Response', - full_name='manga.Response', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='success', full_name='manga.Response.success', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=3559, - serialized_end=3608, -) - -_BANNER.fields_by_name['action'].message_type = _TRANSITIONACTION -_BANNERLIST.fields_by_name['banners'].message_type = _BANNER -_CHAPTERGROUP.fields_by_name['first_chapter_list'].message_type = _CHAPTER -_CHAPTERGROUP.fields_by_name['mid_chapter_list'].message_type = _CHAPTER -_CHAPTERGROUP.fields_by_name['last_chapter_list'].message_type = _CHAPTER -_ADNETWORKLIST_ADNETWORK_FACEBOOK.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_ADMOB.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_MOPUB.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_ADSENSE.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_APPLOVIN.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK.fields_by_name['facebook'].message_type = _ADNETWORKLIST_ADNETWORK_FACEBOOK -_ADNETWORKLIST_ADNETWORK.fields_by_name['admob'].message_type = _ADNETWORKLIST_ADNETWORK_ADMOB -_ADNETWORKLIST_ADNETWORK.fields_by_name['mopub'].message_type = _ADNETWORKLIST_ADNETWORK_MOPUB -_ADNETWORKLIST_ADNETWORK.fields_by_name['adsense'].message_type = _ADNETWORKLIST_ADNETWORK_ADSENSE -_ADNETWORKLIST_ADNETWORK.fields_by_name['applovin'].message_type = _ADNETWORKLIST_ADNETWORK_APPLOVIN -_ADNETWORKLIST_ADNETWORK.containing_type = _ADNETWORKLIST -_ADNETWORKLIST.fields_by_name['ad_networks'].message_type = _ADNETWORKLIST_ADNETWORK -_POPUP_BUTTON.fields_by_name['action'].message_type = _TRANSITIONACTION -_POPUP_BUTTON.containing_type = _POPUP -_POPUP_OSDEFAULT.fields_by_name['ok_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.fields_by_name['neutral_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.fields_by_name['cancel_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.containing_type = _POPUP -_POPUP_APPDEFAULT.fields_by_name['action'].message_type = _TRANSITIONACTION -_POPUP_APPDEFAULT.containing_type = _POPUP -_POPUP_MOVIEREWARD.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_POPUP_MOVIEREWARD.containing_type = _POPUP -_POPUP.fields_by_name['os_default'].message_type = _POPUP_OSDEFAULT -_POPUP.fields_by_name['app_default'].message_type = _POPUP_APPDEFAULT -_POPUP.fields_by_name['movie_reward'].message_type = _POPUP_MOVIEREWARD -_LASTPAGE.fields_by_name['current_chapter'].message_type = _CHAPTER -_LASTPAGE.fields_by_name['next_chapter'].message_type = _CHAPTER -_LASTPAGE.fields_by_name['top_comments'].message_type = _COMMENT -_LASTPAGE.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_LASTPAGE.fields_by_name['movie_reward'].message_type = _POPUP -_PAGE.fields_by_name['manga_page'].message_type = _MANGAPAGE -_PAGE.fields_by_name['banner_list'].message_type = _BANNERLIST -_PAGE.fields_by_name['last_page'].message_type = _LASTPAGE -_PAGE.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_MANGAVIEWER.fields_by_name['pages'].message_type = _PAGE -_MANGAVIEWER.fields_by_name['chapters'].message_type = _CHAPTER -_MANGAVIEWER.fields_by_name['sns'].message_type = _SNS -_TITLEDETAILVIEW.fields_by_name['title'].message_type = _TITLE -_TITLEDETAILVIEW.fields_by_name['first_chapter_list'].message_type = _CHAPTER -_TITLEDETAILVIEW.fields_by_name['last_chapter_list'].message_type = _CHAPTER -_TITLEDETAILVIEW.fields_by_name['banners'].message_type = _BANNER -_TITLEDETAILVIEW.fields_by_name['recommended_title_list'].message_type = _TITLE -_TITLEDETAILVIEW.fields_by_name['sns'].message_type = _SNS -_TITLEDETAILVIEW.fields_by_name['chapter_list_group'].message_type = _CHAPTERGROUP -_SUCCESSRESULT.fields_by_name['title_detail_view'].message_type = _TITLEDETAILVIEW -_SUCCESSRESULT.fields_by_name['manga_viewer'].message_type = _MANGAVIEWER -_RESPONSE.fields_by_name['success'].message_type = _SUCCESSRESULT -DESCRIPTOR.message_types_by_name['Banner'] = _BANNER -DESCRIPTOR.message_types_by_name['BannerList'] = _BANNERLIST -DESCRIPTOR.message_types_by_name['TransitionAction'] = _TRANSITIONACTION -DESCRIPTOR.message_types_by_name['Chapter'] = _CHAPTER -DESCRIPTOR.message_types_by_name['ChapterGroup'] = _CHAPTERGROUP -DESCRIPTOR.message_types_by_name['Comment'] = _COMMENT -DESCRIPTOR.message_types_by_name['AdNetworkList'] = _ADNETWORKLIST -DESCRIPTOR.message_types_by_name['Popup'] = _POPUP -DESCRIPTOR.message_types_by_name['LastPage'] = _LASTPAGE -DESCRIPTOR.message_types_by_name['MangaPage'] = _MANGAPAGE -DESCRIPTOR.message_types_by_name['Page'] = _PAGE -DESCRIPTOR.message_types_by_name['Sns'] = _SNS -DESCRIPTOR.message_types_by_name['MangaViewer'] = _MANGAVIEWER -DESCRIPTOR.message_types_by_name['Title'] = _TITLE -DESCRIPTOR.message_types_by_name['TitleDetailView'] = _TITLEDETAILVIEW -DESCRIPTOR.message_types_by_name['SuccessResult'] = _SUCCESSRESULT -DESCRIPTOR.message_types_by_name['Response'] = _RESPONSE -_sym_db.RegisterFileDescriptor(DESCRIPTOR) - -Banner = _reflection.GeneratedProtocolMessageType('Banner', (_message.Message,), { - 'DESCRIPTOR' : _BANNER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Banner) - }) -_sym_db.RegisterMessage(Banner) - -BannerList = _reflection.GeneratedProtocolMessageType('BannerList', (_message.Message,), { - 'DESCRIPTOR' : _BANNERLIST, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.BannerList) - }) -_sym_db.RegisterMessage(BannerList) - -TransitionAction = _reflection.GeneratedProtocolMessageType('TransitionAction', (_message.Message,), { - 'DESCRIPTOR' : _TRANSITIONACTION, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.TransitionAction) - }) -_sym_db.RegisterMessage(TransitionAction) - -Chapter = _reflection.GeneratedProtocolMessageType('Chapter', (_message.Message,), { - 'DESCRIPTOR' : _CHAPTER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Chapter) - }) -_sym_db.RegisterMessage(Chapter) - -ChapterGroup = _reflection.GeneratedProtocolMessageType('ChapterGroup', (_message.Message,), { - 'DESCRIPTOR' : _CHAPTERGROUP, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.ChapterGroup) - }) -_sym_db.RegisterMessage(ChapterGroup) - -Comment = _reflection.GeneratedProtocolMessageType('Comment', (_message.Message,), { - 'DESCRIPTOR' : _COMMENT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Comment) - }) -_sym_db.RegisterMessage(Comment) - -AdNetworkList = _reflection.GeneratedProtocolMessageType('AdNetworkList', (_message.Message,), { - - 'AdNetwork' : _reflection.GeneratedProtocolMessageType('AdNetwork', (_message.Message,), { - - 'Facebook' : _reflection.GeneratedProtocolMessageType('Facebook', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_FACEBOOK, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Facebook) - }) - , - - 'Admob' : _reflection.GeneratedProtocolMessageType('Admob', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_ADMOB, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Admob) - }) - , - - 'Mopub' : _reflection.GeneratedProtocolMessageType('Mopub', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_MOPUB, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Mopub) - }) - , - - 'Adsense' : _reflection.GeneratedProtocolMessageType('Adsense', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_ADSENSE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Adsense) - }) - , - - 'Applovin' : _reflection.GeneratedProtocolMessageType('Applovin', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_APPLOVIN, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Applovin) - }) - , - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork) - }) - , - 'DESCRIPTOR' : _ADNETWORKLIST, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList) - }) -_sym_db.RegisterMessage(AdNetworkList) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Facebook) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Admob) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Mopub) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Adsense) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Applovin) - -Popup = _reflection.GeneratedProtocolMessageType('Popup', (_message.Message,), { - - 'Button' : _reflection.GeneratedProtocolMessageType('Button', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_BUTTON, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.Button) - }) - , - - 'OSDefault' : _reflection.GeneratedProtocolMessageType('OSDefault', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_OSDEFAULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.OSDefault) - }) - , - - 'AppDefault' : _reflection.GeneratedProtocolMessageType('AppDefault', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_APPDEFAULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.AppDefault) - }) - , - - 'MovieReward' : _reflection.GeneratedProtocolMessageType('MovieReward', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_MOVIEREWARD, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.MovieReward) - }) - , - 'DESCRIPTOR' : _POPUP, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup) - }) -_sym_db.RegisterMessage(Popup) -_sym_db.RegisterMessage(Popup.Button) -_sym_db.RegisterMessage(Popup.OSDefault) -_sym_db.RegisterMessage(Popup.AppDefault) -_sym_db.RegisterMessage(Popup.MovieReward) - -LastPage = _reflection.GeneratedProtocolMessageType('LastPage', (_message.Message,), { - 'DESCRIPTOR' : _LASTPAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.LastPage) - }) -_sym_db.RegisterMessage(LastPage) - -MangaPage = _reflection.GeneratedProtocolMessageType('MangaPage', (_message.Message,), { - 'DESCRIPTOR' : _MANGAPAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.MangaPage) - }) -_sym_db.RegisterMessage(MangaPage) - -Page = _reflection.GeneratedProtocolMessageType('Page', (_message.Message,), { - 'DESCRIPTOR' : _PAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Page) - }) -_sym_db.RegisterMessage(Page) - -Sns = _reflection.GeneratedProtocolMessageType('Sns', (_message.Message,), { - 'DESCRIPTOR' : _SNS, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Sns) - }) -_sym_db.RegisterMessage(Sns) - -MangaViewer = _reflection.GeneratedProtocolMessageType('MangaViewer', (_message.Message,), { - 'DESCRIPTOR' : _MANGAVIEWER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.MangaViewer) - }) -_sym_db.RegisterMessage(MangaViewer) - -Title = _reflection.GeneratedProtocolMessageType('Title', (_message.Message,), { - 'DESCRIPTOR' : _TITLE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Title) - }) -_sym_db.RegisterMessage(Title) - -TitleDetailView = _reflection.GeneratedProtocolMessageType('TitleDetailView', (_message.Message,), { - 'DESCRIPTOR' : _TITLEDETAILVIEW, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.TitleDetailView) - }) -_sym_db.RegisterMessage(TitleDetailView) - -SuccessResult = _reflection.GeneratedProtocolMessageType('SuccessResult', (_message.Message,), { - 'DESCRIPTOR' : _SUCCESSRESULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.SuccessResult) - }) -_sym_db.RegisterMessage(SuccessResult) - -Response = _reflection.GeneratedProtocolMessageType('Response', (_message.Message,), { - 'DESCRIPTOR' : _RESPONSE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Response) - }) -_sym_db.RegisterMessage(Response) - - +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eresponse.proto\x12\x05manga\"P\n\x06\x42\x61nner\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x12\n\n\x02id\x18\x03 \x01(\r\"B\n\nBannerList\x12\x14\n\x0c\x62\x61nner_title\x18\x01 \x01(\t\x12\x1e\n\x07\x62\x61nners\x18\x02 \x03(\x0b\x32\r.manga.Banner\"/\n\x10TransitionAction\x12\x0e\n\x06method\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\"\xc9\x01\n\x07\x43hapter\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x11\n\tsub_title\x18\x04 \x01(\t\x12\x15\n\rthumbnail_url\x18\x05 \x01(\t\x12\x17\n\x0fstart_timestamp\x18\x06 \x01(\r\x12\x15\n\rend_timestamp\x18\x07 \x01(\r\x12\x16\n\x0e\x61lready_viewed\x18\x08 \x01(\x08\x12\x18\n\x10is_vertical_only\x18\t \x01(\x08\"\xa8\x01\n\x0c\x43hapterGroup\x12\x17\n\x0f\x63hapter_numbers\x18\x01 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\x02 \x03(\x0b\x32\x0e.manga.Chapter\x12(\n\x10mid_chapter_list\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\x04 \x03(\x0b\x32\x0e.manga.Chapter\"\xdd\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05index\x18\x02 \x01(\r\x12\x11\n\tuser_name\x18\x03 \x01(\t\x12\x10\n\x08icon_url\x18\x04 \x01(\t\x12\x1a\n\ris_my_comment\x18\x06 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\ralready_liked\x18\x07 \x01(\x08H\x01\x88\x01\x01\x12\x17\n\x0fnumber_of_likes\x18\t \x01(\r\x12\x0c\n\x04\x62ody\x18\n \x01(\t\x12\x0f\n\x07\x63reated\x18\x0b \x01(\rB\x10\n\x0e_is_my_commentB\x10\n\x0e_already_liked\"\xfa\x03\n\rAdNetworkList\x12\x33\n\x0b\x61\x64_networks\x18\x01 \x01(\x0b\x32\x1e.manga.AdNetworkList.AdNetwork\x1a\xb3\x03\n\tAdNetwork\x12\x39\n\x08\x66\x61\x63\x65\x62ook\x18\x01 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Facebook\x12\x33\n\x05\x61\x64mob\x18\x02 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Admob\x12\x33\n\x05mopub\x18\x03 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Mopub\x12\x37\n\x07\x61\x64sense\x18\x04 \x01(\x0b\x32&.manga.AdNetworkList.AdNetwork.Adsense\x12\x39\n\x08\x61pplovin\x18\x05 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Applovin\x1a \n\x08\x46\x61\x63\x65\x62ook\x12\x14\n\x0cplacement_id\x18\x01 \x01(\t\x1a\x18\n\x05\x41\x64mob\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x18\n\x05Mopub\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1a\n\x07\x41\x64sense\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1b\n\x08\x41pplovin\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\"\xb8\x04\n\x05Popup\x12*\n\nos_default\x18\x01 \x01(\x0b\x32\x16.manga.Popup.OSDefault\x12,\n\x0b\x61pp_default\x18\x02 \x01(\x0b\x32\x17.manga.Popup.AppDefault\x12.\n\x0cmovie_reward\x18\x03 \x01(\x0b\x32\x18.manga.Popup.MovieReward\x1a?\n\x06\x42utton\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x1a\xab\x01\n\tOSDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12&\n\tok_button\x18\x03 \x01(\x0b\x32\x13.manga.Popup.Button\x12+\n\x0eneutral_button\x18\x04 \x01(\x0b\x32\x13.manga.Popup.Button\x12*\n\rcancel_button\x18\x05 \x01(\x0b\x32\x13.manga.Popup.Button\x1ag\n\nAppDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x03 \x01(\x0b\x32\x17.manga.TransitionAction\x12\x11\n\timage_url\x18\x04 \x01(\t\x1aM\n\x0bMovieReward\x12\x11\n\timage_url\x18\x01 \x01(\t\x12+\n\radvertisement\x18\x02 \x01(\x0b\x32\x14.manga.AdNetworkList\"\x95\x02\n\x08LastPage\x12\'\n\x0f\x63urrent_chapter\x18\x01 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0cnext_chapter\x18\x02 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0ctop_comments\x18\x03 \x03(\x0b\x32\x0e.manga.Comment\x12\x15\n\ris_subscribed\x18\x04 \x01(\x08\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x14\n\x0c\x63hapter_type\x18\x06 \x01(\x05\x12+\n\radvertisement\x18\x07 \x01(\x0b\x32\x14.manga.AdNetworkList\x12\"\n\x0cmovie_reward\x18\x08 \x01(\x0b\x32\x0c.manga.Popup\"c\n\tMangaPage\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\r\n\x05width\x18\x02 \x01(\r\x12\x0e\n\x06height\x18\x03 \x01(\r\x12\x0c\n\x04type\x18\x04 \x01(\x05\x12\x16\n\x0e\x65ncryption_key\x18\x05 \x01(\t\"\xa5\x01\n\x04Page\x12$\n\nmanga_page\x18\x01 \x01(\x0b\x32\x10.manga.MangaPage\x12&\n\x0b\x62\x61nner_list\x18\x02 \x01(\x0b\x32\x11.manga.BannerList\x12\"\n\tlast_page\x18\x03 \x01(\x0b\x32\x0f.manga.LastPage\x12+\n\radvertisement\x18\x04 \x01(\x0b\x32\x14.manga.AdNetworkList\" \n\x03Sns\x12\x0c\n\x04\x62ody\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\"\x84\x02\n\x0bMangaViewer\x12\x1a\n\x05pages\x18\x01 \x03(\x0b\x32\x0b.manga.Page\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12 \n\x08\x63hapters\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12\x17\n\x03sns\x18\x04 \x01(\x0b\x32\n.manga.Sns\x12\x12\n\ntitle_name\x18\x05 \x01(\t\x12\x14\n\x0c\x63hapter_name\x18\x06 \x01(\t\x12\x1a\n\x12number_of_comments\x18\x07 \x01(\r\x12\x18\n\x10is_vertical_only\x18\x08 \x01(\x08\x12\x10\n\x08title_id\x18\t \x01(\r\x12\x18\n\x10start_from_right\x18\n \x01(\x08\"\x96\x01\n\x05Title\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x1a\n\x12portrait_image_url\x18\x04 \x01(\t\x12\x1b\n\x13landscape_image_url\x18\x05 \x01(\t\x12\x12\n\nview_count\x18\x06 \x01(\r\x12\x10\n\x08language\x18\x07 \x01(\x05\"&\n\x08TitleTag\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04slug\x18\x02 \x01(\t\"\x93\x05\n\x0fTitleDetailView\x12\x1b\n\x05title\x18\x01 \x01(\x0b\x32\x0c.manga.Title\x12\x17\n\x0ftitle_image_url\x18\x02 \x01(\t\x12\x10\n\x08overview\x18\x03 \x01(\t\x12\x1c\n\x14\x62\x61\x63kground_image_url\x18\x04 \x01(\t\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x15\n\rupdate_timing\x18\x06 \x01(\x05\x12\"\n\x1aviewing_period_description\x18\x07 \x01(\t\x12\x1b\n\x13non_appearance_info\x18\x08 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\t \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\n \x03(\x0b\x32\x0e.manga.Chapter\x12\x1e\n\x07\x62\x61nners\x18\x0b \x03(\x0b\x32\r.manga.Banner\x12,\n\x16recommended_title_list\x18\x0c \x03(\x0b\x32\x0c.manga.Title\x12\x17\n\x03sns\x18\r \x01(\x0b\x32\n.manga.Sns\x12\x19\n\x11is_simul_released\x18\x0e \x01(\x08\x12\x15\n\ris_subscribed\x18\x0f \x01(\x08\x12\x0e\n\x06rating\x18\x10 \x01(\x05\x12\x1b\n\x13\x63hapters_descending\x18\x11 \x01(\x08\x12\x17\n\x0fnumber_of_views\x18\x12 \x01(\r\x12/\n\x12\x63hapter_list_group\x18\x1c \x03(\x0b\x32\x13.manga.ChapterGroup\x12\x1d\n\x04tags\x18\x1f \x03(\x0b\x32\x0f.manga.TitleTag\x12$\n\x0c\x63hapter_list\x18& \x03(\x0b\x32\x0e.manga.Chapter\"B\n\x0e\x41llTitlesGroup\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x1c\n\x06titles\x18\x02 \x03(\x0b\x32\x0c.manga.Title\"<\n\rAllTitlesView\x12+\n\x0ctitle_groups\x18\x01 \x03(\x0b\x32\x15.manga.AllTitlesGroup\"\x9b\x01\n\rSuccessResult\x12\x31\n\x11title_detail_view\x18\x08 \x01(\x0b\x32\x16.manga.TitleDetailView\x12(\n\x0cmanga_viewer\x18\n \x01(\x0b\x32\x12.manga.MangaViewer\x12-\n\x0f\x61ll_titles_view\x18\x19 \x01(\x0b\x32\x14.manga.AllTitlesView\"1\n\x08Response\x12%\n\x07success\x18\x01 \x01(\x0b\x32\x14.manga.SuccessResultB\x0fH\x01Z\x0bmanga/protob\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'response_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'H\001Z\013manga/proto' + _globals['_BANNER']._serialized_start=25 + _globals['_BANNER']._serialized_end=105 + _globals['_BANNERLIST']._serialized_start=107 + _globals['_BANNERLIST']._serialized_end=173 + _globals['_TRANSITIONACTION']._serialized_start=175 + _globals['_TRANSITIONACTION']._serialized_end=222 + _globals['_CHAPTER']._serialized_start=225 + _globals['_CHAPTER']._serialized_end=426 + _globals['_CHAPTERGROUP']._serialized_start=429 + _globals['_CHAPTERGROUP']._serialized_end=597 + _globals['_COMMENT']._serialized_start=600 + _globals['_COMMENT']._serialized_end=821 + _globals['_ADNETWORKLIST']._serialized_start=824 + _globals['_ADNETWORKLIST']._serialized_end=1330 + _globals['_ADNETWORKLIST_ADNETWORK']._serialized_start=895 + _globals['_ADNETWORKLIST_ADNETWORK']._serialized_end=1330 + _globals['_ADNETWORKLIST_ADNETWORK_FACEBOOK']._serialized_start=1189 + _globals['_ADNETWORKLIST_ADNETWORK_FACEBOOK']._serialized_end=1221 + _globals['_ADNETWORKLIST_ADNETWORK_ADMOB']._serialized_start=1223 + _globals['_ADNETWORKLIST_ADNETWORK_ADMOB']._serialized_end=1247 + _globals['_ADNETWORKLIST_ADNETWORK_MOPUB']._serialized_start=1249 + _globals['_ADNETWORKLIST_ADNETWORK_MOPUB']._serialized_end=1273 + _globals['_ADNETWORKLIST_ADNETWORK_ADSENSE']._serialized_start=1275 + _globals['_ADNETWORKLIST_ADNETWORK_ADSENSE']._serialized_end=1301 + _globals['_ADNETWORKLIST_ADNETWORK_APPLOVIN']._serialized_start=1303 + _globals['_ADNETWORKLIST_ADNETWORK_APPLOVIN']._serialized_end=1330 + _globals['_POPUP']._serialized_start=1333 + _globals['_POPUP']._serialized_end=1901 + _globals['_POPUP_BUTTON']._serialized_start=1480 + _globals['_POPUP_BUTTON']._serialized_end=1543 + _globals['_POPUP_OSDEFAULT']._serialized_start=1546 + _globals['_POPUP_OSDEFAULT']._serialized_end=1717 + _globals['_POPUP_APPDEFAULT']._serialized_start=1719 + _globals['_POPUP_APPDEFAULT']._serialized_end=1822 + _globals['_POPUP_MOVIEREWARD']._serialized_start=1824 + _globals['_POPUP_MOVIEREWARD']._serialized_end=1901 + _globals['_LASTPAGE']._serialized_start=1904 + _globals['_LASTPAGE']._serialized_end=2181 + _globals['_MANGAPAGE']._serialized_start=2183 + _globals['_MANGAPAGE']._serialized_end=2282 + _globals['_PAGE']._serialized_start=2285 + _globals['_PAGE']._serialized_end=2450 + _globals['_SNS']._serialized_start=2452 + _globals['_SNS']._serialized_end=2484 + _globals['_MANGAVIEWER']._serialized_start=2487 + _globals['_MANGAVIEWER']._serialized_end=2747 + _globals['_TITLE']._serialized_start=2750 + _globals['_TITLE']._serialized_end=2900 + _globals['_TITLETAG']._serialized_start=2902 + _globals['_TITLETAG']._serialized_end=2940 + _globals['_TITLEDETAILVIEW']._serialized_start=2943 + _globals['_TITLEDETAILVIEW']._serialized_end=3602 + _globals['_ALLTITLESGROUP']._serialized_start=3604 + _globals['_ALLTITLESGROUP']._serialized_end=3670 + _globals['_ALLTITLESVIEW']._serialized_start=3672 + _globals['_ALLTITLESVIEW']._serialized_end=3732 + _globals['_SUCCESSRESULT']._serialized_start=3735 + _globals['_SUCCESSRESULT']._serialized_end=3890 + _globals['_RESPONSE']._serialized_start=3892 + _globals['_RESPONSE']._serialized_end=3941 # @@protoc_insertion_point(module_scope) diff --git a/mloader/response_pb2.pyi b/mloader/response_pb2.pyi new file mode 100644 index 0000000..1373f42 --- /dev/null +++ b/mloader/response_pb2.pyi @@ -0,0 +1,23 @@ +"""Typing stub for generated MangaPlus protobuf classes.""" + +from __future__ import annotations + +from typing import Any, Self + +from google.protobuf.message import Message + +class _DynamicMessage(Message): + """Dynamic protobuf message surface generated at runtime.""" + + def __getattr__(self, name: str) -> Any: ... + def __setattr__(self, name: str, value: Any) -> None: ... + def HasField(self, field_name: str) -> bool: ... + def ListFields(self) -> list[tuple[Any, Any]]: ... + def SerializeToString(self, **kwargs: Any) -> bytes: ... + @classmethod + def FromString(cls, s: Any) -> Self: ... + +class Response(_DynamicMessage): + """Top-level MangaPlus response message.""" + + success: Any diff --git a/mloader/types.py b/mloader/types.py new file mode 100644 index 0000000..cf7d602 --- /dev/null +++ b/mloader/types.py @@ -0,0 +1,93 @@ +"""Typed protocol contracts shared across runtime components.""" + +from __future__ import annotations + +from typing import Mapping, MutableMapping, Protocol + +PageIndex = int | range + + +class ChapterLike(Protocol): + """Minimal chapter shape used by loader and exporter code.""" + + chapter_id: int + name: str + sub_title: str + thumbnail_url: str + + +class TitleLike(Protocol): + """Minimal title shape used by loader and exporter code.""" + + name: str + author: str + portrait_image_url: str + landscape_image_url: str + language: int + + +class ResponseLike(Protocol): + """Minimal HTTP response contract used by loader transport code.""" + + content: bytes + + def raise_for_status(self) -> None: + """Raise for non-successful HTTP responses.""" + + +class SessionLike(Protocol): + """Minimal HTTP session contract used by runtime transport code.""" + + headers: MutableMapping[str, str] + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> ResponseLike: + """Perform an HTTP GET request and return a response object.""" + + def mount(self, prefix: str, adapter: object) -> None: + """Attach a transport adapter for matching URL prefixes.""" + + +class ExporterLike(Protocol): + """Minimal exporter contract used by downloader orchestration.""" + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Persist one image payload.""" + + def skip_image(self, index: PageIndex) -> bool: + """Return whether a page index should be skipped.""" + + def close(self) -> None: + """Finalize exporter output.""" + + +class ExporterFactoryLike(Protocol): + """Factory contract used by loader to construct exporters per chapter.""" + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + """Create and return an exporter instance.""" + + +class PayloadCaptureLike(Protocol): + """Contract for persisting API payload captures.""" + + def capture( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Persist payload capture artifacts.""" diff --git a/mloader/utils.py b/mloader/utils.py index 33baf43..759d865 100644 --- a/mloader/utils.py +++ b/mloader/utils.py @@ -1,32 +1,45 @@ -import re -import string -import sys -from typing import Optional - - -def is_oneshot(chapter_name: str, chapter_subtitle: str) -> bool: - chapter_number = chapter_name_to_int(chapter_name) - - if chapter_number is not None: - return False - - for name in (chapter_name, chapter_subtitle): - name = name.lower() - if "one" in name and "shot" in name: - return True - return False - - -def chapter_name_to_int(name: str) -> Optional[int]: - try: - return int(name.lstrip("#")) - except ValueError: - return None - - -def escape_path(path: str) -> str: - return re.sub(r"[^\w]+", " ", path).strip(string.punctuation + " ") - - -def is_windows() -> bool: - return sys.platform == "win32" +"""Generic utility helpers for chapter parsing and filename sanitization.""" + +from __future__ import annotations + +import re +import string +import sys +from typing import Collection + + +def _contains_keywords(text: str, keywords: Collection[str]) -> bool: + """Return whether ``text`` contains all ``keywords`` case-insensitively.""" + lower_text = text.lower() + return all(keyword.lower() in lower_text for keyword in keywords) + + +def is_oneshot(chapter_name: str, chapter_subtitle: str) -> bool: + """Return whether chapter metadata indicates one-shot content.""" + chapter_number = chapter_name_to_int(chapter_name) + if chapter_number is not None: + return False + + return _contains_keywords(chapter_name, ["one", "shot"]) or _contains_keywords( + chapter_subtitle, + ["one", "shot"], + ) + + +def chapter_name_to_int(name: str) -> int | None: + """Parse chapter numeric value from ``name``, returning ``None`` if invalid.""" + try: + return int(name.lstrip("#")) + except ValueError: + return None + + +def escape_path(path: str) -> str: + """Normalize path string for safe filename usage.""" + normalized = re.sub(r"\W+", " ", path) + return normalized.strip(string.punctuation + " ") + + +def is_windows() -> bool: + """Return whether current platform is Windows.""" + return sys.platform == "win32" diff --git a/pyproject.toml b/pyproject.toml index 729c302..ed820ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,90 @@ -[tool.black] -target_version = ['py36'] -line-length = 80 \ No newline at end of file +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "mloader-ng" +version = "2.1.2" +description = "Command-line tool to download manga from mangaplus" +readme = "README.md" +requires-python = ">=3.14" +license = { text = "GPL-3.0-or-later" } +authors = [ + { name = "l0westbob" }, + { name = "Hurlenko" }, +] +dependencies = [ + "Click>=8.3.1", + "protobuf>=6.33.5,<7", + "requests>=2.32.5", + "urllib3>=2.5.0", + "Pillow>=12.0.0", + "img2pdf>=0.6.3", + "python-dotenv>=1.2.1", + "filelock>=3.20.0", + "playwright>=1.55.0", +] + +[project.urls] +Source = "https://github.com/l0westbob/mloader" +Upstream = "https://github.com/hurlenko/mloader" + +[project.scripts] +mloader = "mloader.__main__:main" + +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "pytest-cov>=6.2.0", + "ruff>=0.13.0", + "ty>=0.0.24", +] + +[tool.uv] +default-groups = ["dev"] + +[tool.hatch.build.targets.wheel] +packages = ["mloader"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py314" +line-length = 100 +extend-exclude = ["mloader/response_pb2.py"] + +[tool.ruff.lint] +select = [ + "F", + "D100", + "D101", + "D102", + "D103", + "D104", + "D107", + "ANN001", + "ANN002", + "ANN003", + "ANN201", + "ANN202", + "ANN204", + "ANN205", +] + +[tool.ruff.lint.per-file-ignores] +"mloader/__main__.py" = ["E402"] + +[tool.ty.environment] +python-version = "3.14" + +[tool.coverage.run] +source = ["mloader"] +omit = [ + "mloader/response_pb2.py", + "mloader/**/__init__.py", +] + +[tool.coverage.report] +show_missing = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7fe08c5..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Click>=6.2 -protobuf~=3.6 -requests>=2 diff --git a/response.proto b/response.proto index 4951017..e3cfff1 100644 --- a/response.proto +++ b/response.proto @@ -2,6 +2,9 @@ syntax = "proto3"; package manga; +option optimize_for = SPEED; +option go_package = "manga/proto"; // If using Go + message Banner { string image_url = 1; TransitionAction action = 2; @@ -42,8 +45,8 @@ message Comment { uint32 index = 2; string user_name = 3; string icon_url = 4; - bool is_my_comment = 6; - bool already_liked = 7; + optional bool is_my_comment = 6; + optional bool already_liked = 7; uint32 number_of_likes = 9; string body = 10; uint32 created = 11; @@ -54,7 +57,6 @@ message AdNetworkList { message Facebook { string placement_id = 1; } - message Admob { string unit_id = 1; } @@ -78,7 +80,6 @@ message AdNetworkList { AdNetwork ad_networks = 1; } - message Popup { message Button { string text = 1; @@ -91,7 +92,6 @@ message Popup { Button ok_button = 3; Button neutral_button = 4; Button cancel_button = 5; - } message AppDefault { @@ -111,7 +111,6 @@ message Popup { MovieReward movie_reward = 3; } - message LastPage { Chapter current_chapter = 1; Chapter next_chapter = 2; @@ -123,7 +122,6 @@ message LastPage { Popup movie_reward = 8; } -// MangaPage message MangaPage { string image_url = 1; uint32 width = 2; @@ -132,7 +130,6 @@ message MangaPage { string encryption_key = 5; } -// Page message Page { MangaPage manga_page = 1; BannerList banner_list = 2; @@ -145,7 +142,6 @@ message Sns { string url = 2; } -// MangaViewer message MangaViewer { repeated Page pages = 1; uint32 chapter_id = 2; @@ -169,6 +165,11 @@ message Title { int32 language = 7; } +message TitleTag { + string name = 1; + string slug = 2; +} + message TitleDetailView { Title title = 1; string title_image_url = 2; @@ -189,11 +190,23 @@ message TitleDetailView { bool chapters_descending = 17; uint32 number_of_views = 18; repeated ChapterGroup chapter_list_group = 28; + repeated TitleTag tags = 31; + repeated Chapter chapter_list = 38; +} + +message AllTitlesGroup { + string group_name = 1; + repeated Title titles = 2; +} + +message AllTitlesView { + repeated AllTitlesGroup title_groups = 1; } message SuccessResult { TitleDetailView title_detail_view = 8; MangaViewer manga_viewer = 10; + AllTitlesView all_titles_view = 25; } message Response { diff --git a/scripts/sync_readme_cli_reference.py b/scripts/sync_readme_cli_reference.py new file mode 100644 index 0000000..132772d --- /dev/null +++ b/scripts/sync_readme_cli_reference.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Sync README CLI reference section with Click command metadata.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from mloader.cli.main import main as cli_main +from mloader.cli.readme_reference import replace_readme_cli_reference + + +def _parse_args() -> argparse.Namespace: + """Parse script command-line flags.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Exit with non-zero status when README is out of sync.", + ) + parser.add_argument( + "--readme", + type=Path, + default=Path("README.md"), + help="Path to README file to update.", + ) + return parser.parse_args() + + +def main() -> int: + """Run README sync in update or check mode.""" + args = _parse_args() + readme_path: Path = args.readme + original = readme_path.read_text(encoding="utf-8") + updated = replace_readme_cli_reference(original, command=cli_main) + + if args.check: + if updated != original: + print("README CLI reference is out of sync. Run scripts/sync_readme_cli_reference.py.") + return 1 + print("README CLI reference is up to date.") + return 0 + + readme_path.write_text(updated, encoding="utf-8") + print(f"Updated {readme_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_readme_examples.py b/scripts/verify_readme_examples.py new file mode 100644 index 0000000..7c30ccd --- /dev/null +++ b/scripts/verify_readme_examples.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Validate README mloader examples against live MangaPlus API endpoints.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import re +import shlex +from pathlib import Path +from typing import Iterable + +import requests + +from mloader.cli.examples import build_cli_examples +from mloader.domain.manga import TitleDetail +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import auth +from mloader.infrastructure.mangaplus.settings import ( + MANGA_VIEWER_PATH, + MOBILE_API_HEADERS, + TITLE_DETAIL_PATH, + DEFAULT_API_BASE_URL, + api_url, +) +from mloader.infrastructure.mangaplus.parsing import ( + parse_manga_viewer_response, + parse_title_detail_response, +) +from mloader.utils import chapter_name_to_int + +MANGA_PLUS_HOST = "mangaplus.shueisha.co.jp" +VIEWER_URL_PATTERN = re.compile(rf"^https://{re.escape(MANGA_PLUS_HOST)}/viewer/(\d+)$") +TITLE_URL_PATTERN = re.compile(rf"^https://{re.escape(MANGA_PLUS_HOST)}/titles/(\d+)$") +BASH_BLOCK_PATTERN = re.compile(r"```bash\s*\n(.*?)```", re.DOTALL) +MANGA_VIEWER_ENDPOINT = api_url(DEFAULT_API_BASE_URL, MANGA_VIEWER_PATH) +TITLE_DETAIL_ENDPOINT = api_url(DEFAULT_API_BASE_URL, TITLE_DETAIL_PATH) + + +@dataclass(slots=True) +class ParsedCommand: + """Normalized target values extracted from one README command.""" + + source: str + command: str + title_ids: set[int] + chapter_ids: set[int] + chapter_numbers: set[int] + + +@dataclass(slots=True) +class ValidationIssue: + """One validation issue found for a README command target.""" + + command: str + message: str + + +@dataclass(slots=True) +class SkippedCommand: + """One command skipped from live validation with explicit reason.""" + + source: str + command: str + reason: str + + +def _extract_commands(readme_text: str) -> list[str]: + """Return all README bash codeblock lines that begin with ``mloader ``.""" + commands: list[str] = [] + for block_match in BASH_BLOCK_PATTERN.finditer(readme_text): + block = block_match.group(1) + for raw_line in block.splitlines(): + line = raw_line.strip() + if line.startswith("mloader "): + commands.append(line) + return commands + + +def _parse_command(command: str, *, source: str) -> ParsedCommand: + """Parse one command line and return discovered title/chapter targets.""" + tokens = shlex.split(command) + args = tokens[1:] + title_ids: set[int] = set() + chapter_ids: set[int] = set() + chapter_numbers: set[int] = set() + index = 0 + + while index < len(args): + token = args[index] + + if token in {"--title", "-t"} and index + 1 < len(args): + title_ids.add(int(args[index + 1])) + index += 2 + continue + + if token == "--chapter-id" and index + 1 < len(args): + chapter_ids.add(int(args[index + 1])) + index += 2 + continue + + if token in {"--chapter", "-c"} and index + 1 < len(args): + chapter_numbers.add(int(args[index + 1])) + index += 2 + continue + + viewer_match = VIEWER_URL_PATTERN.match(token) + if viewer_match: + chapter_ids.add(int(viewer_match.group(1))) + index += 1 + continue + + title_match = TITLE_URL_PATTERN.match(token) + if title_match: + title_ids.add(int(title_match.group(1))) + index += 1 + continue + + index += 1 + + return ParsedCommand( + source=source, + command=command, + title_ids=title_ids, + chapter_ids=chapter_ids, + chapter_numbers=chapter_numbers, + ) + + +def _unique_commands(commands: Iterable[str]) -> list[str]: + """Return de-duplicated commands while preserving first-seen order.""" + seen: set[str] = set() + result: list[str] = [] + for command in commands: + if command in seen: + continue + seen.add(command) + result.append(command) + return result + + +def _build_parsed_commands( + *, + readme_text: str, + include_cli_examples: bool, +) -> list[ParsedCommand]: + """Build parsed command list from README and optional CLI example catalog.""" + readme_commands = _unique_commands(_extract_commands(readme_text)) + parsed_commands = [_parse_command(command, source="README") for command in readme_commands] + + if include_cli_examples: + example_commands = _unique_commands( + example.command for example in build_cli_examples(prog_name="mloader") + ) + parsed_commands.extend( + _parse_command(command, source="CLI_EXAMPLES") for command in example_commands + ) + + return parsed_commands + + +def _split_validatable_commands( + parsed_commands: Iterable[ParsedCommand], +) -> tuple[list[ParsedCommand], list[SkippedCommand]]: + """Split commands into live-validatable and skipped categories.""" + validatable: list[ParsedCommand] = [] + skipped: list[SkippedCommand] = [] + + for command in parsed_commands: + has_explicit_targets = bool(command.title_ids or command.chapter_ids) + if has_explicit_targets: + validatable.append(command) + continue + + if command.chapter_numbers and not command.title_ids: + skipped.append( + SkippedCommand( + source=command.source, + command=command.command, + reason="chapter numbers provided without title IDs", + ) + ) + continue + + skipped.append( + SkippedCommand( + source=command.source, + command=command.command, + reason="no resolvable title/chapter targets in command", + ) + ) + + return validatable, skipped + + +def _all_chapter_numbers_for_title(title_detail: TitleDetail) -> set[int]: + """Extract numeric chapter numbers from all chapter groups in a title payload.""" + chapter_numbers: set[int] = set() + for chapter in title_detail.chapters: + parsed_number = chapter_name_to_int(chapter.name) + if parsed_number is not None: + chapter_numbers.add(parsed_number) + return chapter_numbers + + +def _validate_targets( + commands: Iterable[ParsedCommand], + *, + timeout: tuple[float, float], +) -> list[ValidationIssue]: + """Validate README targets against live API and return all discovered issues.""" + session = requests.Session() + session.headers.update(MOBILE_API_HEADERS) + issues: list[ValidationIssue] = [] + title_cache: dict[int, TitleDetail] = {} + + def _fetch_title(title_id: int, command: str) -> TitleDetail | None: + if title_id in title_cache: + return title_cache[title_id] + params = {**auth.auth_params(), "title_id": title_id} + try: + response = session.get(TITLE_DETAIL_ENDPOINT, params=params, timeout=timeout) + response.raise_for_status() + parsed = parse_title_detail_response(response.content) + except (requests.RequestException, APIResponseError) as error: + issues.append( + ValidationIssue( + command=command, + message=f"title {title_id} failed: {error}", + ) + ) + return None + title_cache[title_id] = parsed + return parsed + + for parsed_command in commands: + for chapter_id in sorted(parsed_command.chapter_ids): + params = { + **auth.auth_params(), + "chapter_id": chapter_id, + "split": "no", + "img_quality": "low", + } + try: + response = session.get(MANGA_VIEWER_ENDPOINT, params=params, timeout=timeout) + response.raise_for_status() + parse_manga_viewer_response(response.content) + except (requests.RequestException, APIResponseError) as error: + issues.append( + ValidationIssue( + command=parsed_command.command, + message=f"chapter_id {chapter_id} failed: {error}", + ) + ) + + for title_id in sorted(parsed_command.title_ids): + _fetch_title(title_id, parsed_command.command) + + if parsed_command.chapter_numbers and parsed_command.title_ids: + for title_id in sorted(parsed_command.title_ids): + title_dump = _fetch_title(title_id, parsed_command.command) + if title_dump is None: + continue + available_numbers = _all_chapter_numbers_for_title(title_dump) + missing_numbers = sorted(parsed_command.chapter_numbers - available_numbers) + for missing_number in missing_numbers: + issues.append( + ValidationIssue( + command=parsed_command.command, + message=( + f"title {title_id} does not contain chapter number {missing_number}" + ), + ) + ) + + return issues + + +def _build_parser() -> argparse.ArgumentParser: + """Create CLI argument parser.""" + parser = argparse.ArgumentParser( + description="Validate README mloader command examples against live MangaPlus endpoints.", + ) + parser.add_argument( + "--readme", + type=Path, + default=Path("README.md"), + help="Path to README file containing examples.", + ) + parser.add_argument( + "--connect-timeout", + type=float, + default=5.0, + help="HTTP connect timeout in seconds.", + ) + parser.add_argument( + "--read-timeout", + type=float, + default=30.0, + help="HTTP read timeout in seconds.", + ) + parser.add_argument( + "--include-cli-examples", + action=argparse.BooleanOptionalAction, + default=True, + help="Also validate commands from `mloader --show-examples` catalog.", + ) + return parser + + +def main() -> int: + """Run README example verification and return process exit code.""" + parser = _build_parser() + args = parser.parse_args() + + readme_text = args.readme.read_text(encoding="utf-8") + parsed_commands = _build_parsed_commands( + readme_text=readme_text, + include_cli_examples=args.include_cli_examples, + ) + validatable_commands, skipped_commands = _split_validatable_commands(parsed_commands) + + if not validatable_commands: + print("No README examples with resolvable title/chapter targets were found.") + return 0 + + timeout = (args.connect_timeout, args.read_timeout) + issues = _validate_targets(validatable_commands, timeout=timeout) + + if issues: + print( + "README example validation failed: " + f"{len(issues)} issue(s), {len(validatable_commands)} validated, " + f"{len(skipped_commands)} skipped, {len(parsed_commands)} total." + ) + if skipped_commands: + print("Skipped commands:") + for skipped in skipped_commands: + print(f"- [{skipped.source}] {skipped.reason}") + print(f" command: {skipped.command}") + for issue in issues: + print(f"- {issue.message}") + print(f" command: [{issue.command}]") + return 1 + + print("README example validation succeeded.") + print(f"- total commands scanned: {len(parsed_commands)}") + print(f"- commands live-validated: {len(validatable_commands)}") + print(f"- commands skipped: {len(skipped_commands)}") + if skipped_commands: + print("Skipped commands:") + for skipped in skipped_commands: + print(f"- [{skipped.source}] {skipped.reason}") + print(f" command: {skipped.command}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py deleted file mode 100644 index 20a4993..0000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -from codecs import open - -from setuptools import setup, find_packages - -here = os.path.abspath(os.path.dirname(__file__)) - -package_name = "mloader" - -about = {} -with open( - os.path.join(here, package_name, "__version__.py"), "r", "utf-8" -) as f: - exec(f.read(), about) - -with open("README.md", "r", "utf-8") as f: - readme = f.read() - - -setup( - name=about["__title__"], - version=about["__version__"], - description=about["__description__"], - long_description=readme, - long_description_content_type="text/markdown", - url=about["__url__"], - packages=find_packages(), - python_requires=">=3.6", - install_requires=[ - "Click>=6.2", - "protobuf~=3.6", - "requests>=2" - ], - license=about["__license__"], - zip_safe=False, - classifiers=[ - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Programming Language :: Python", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: Implementation :: CPython", - ], - project_urls={"Source": about["__url__"]}, - entry_points={ - "console_scripts": [f"{about['__title__']} = mloader.__main__:main"] - }, -) diff --git a/tests/capture_verify_helpers.py b/tests/capture_verify_helpers.py new file mode 100644 index 0000000..415f477 --- /dev/null +++ b/tests/capture_verify_helpers.py @@ -0,0 +1,67 @@ +"""Shared helpers for capture verification tests.""" + +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +FIXTURE_CAPTURE_DIR = Path(__file__).parent / "fixtures" / "api_captures" / "baseline" + + +def copy_fixture_set(target_dir: Path) -> None: + """Copy baseline capture fixture files into ``target_dir``.""" + target_dir.mkdir(parents=True, exist_ok=True) + for fixture_file in FIXTURE_CAPTURE_DIR.iterdir(): + if fixture_file.is_file(): + (target_dir / fixture_file.name).write_bytes(fixture_file.read_bytes()) + + +def update_payload_metadata(meta_path: Path, payload: bytes) -> None: + """Update metadata checksums/size after payload mutation.""" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_size_bytes"] = len(payload) + metadata["payload_sha256"] = sha256(payload).hexdigest() + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _varint(value: int) -> bytes: + """Encode a protobuf varint for local error-envelope fixtures.""" + parts: list[int] = [] + while True: + byte = value & 0x7F + value >>= 7 + if value: + parts.append(byte | 0x80) + continue + parts.append(byte) + return bytes(parts) + + +def _length_delimited_field(field_number: int, value: bytes) -> bytes: + """Encode one length-delimited protobuf field.""" + return _varint((field_number << 3) | 2) + _varint(len(value)) + value + + +def _varint_field(field_number: int, value: int) -> bytes: + """Encode one varint protobuf field.""" + return _varint(field_number << 3) + _varint(value) + + +def _string_field(field_number: int, value: str) -> bytes: + """Encode one protobuf string field.""" + return _length_delimited_field(field_number, value.encode("utf-8")) + + +def api_error_payload() -> bytes: + """Build a minimal MangaPlus application-error envelope.""" + localized_error = ( + _string_field(1, "Invalid Parameter") + + _string_field( + 2, + "There are issues connecting to Manga+. Please try again later.(10511)", + ) + + _varint_field(6, 0) + ) + error_result = _length_delimited_field(2, localized_error) + return _length_delimited_field(2, error_result) diff --git a/tests/cli_fakes.py b/tests/cli_fakes.py new file mode 100644 index 0000000..3d04eca --- /dev/null +++ b/tests/cli_fakes.py @@ -0,0 +1,295 @@ +"""Shared typed fakes for CLI and application download tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +import requests + +from mloader.domain.requests import ( + CoverFormat, + DownloadSummary, + EffectiveOutputFormat, + FilenameStyle, +) +from mloader.errors import APIResponseError, DownloadInterruptedError, SubscriptionRequiredError +from mloader.types import ChapterLike, ExporterFactoryLike, ExporterLike, PageIndex, TitleLike + +DEFAULT_FAILED_CHAPTER_IDS = (102300, 102301) +DEFAULT_INTERRUPTED_CHAPTER_ID = 102278 + + +@dataclass(frozen=True) +class FakeTitle: + """Minimal title value satisfying exporter factory tests.""" + + name: str = "Title" + author: str = "Author" + portrait_image_url: str = "" + landscape_image_url: str = "" + language: int = 0 + + +@dataclass(frozen=True) +class FakeChapter: + """Minimal chapter value satisfying exporter factory tests.""" + + chapter_id: int = 1 + name: str = "#1" + sub_title: str = "" + thumbnail_url: str = "" + + +class RecordingExporter(ExporterLike): + """Exporter test double that records constructor payloads.""" + + init_args: ClassVar[dict[str, object] | None] = None + calls: ClassVar[list[dict[str, object]]] = [] + + @classmethod + def reset(cls) -> None: + """Reset captured constructor state.""" + cls.init_args = None + cls.calls = [] + + def __init__( + self, + *, + destination: str, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + add_language_to_chapter_name: bool = True, + ) -> None: + """Record exporter constructor arguments.""" + payload: dict[str, object] = { + "destination": destination, + "title": title, + "chapter": chapter, + "next_chapter": next_chapter, + "add_chapter_title": add_chapter_title, + "add_chapter_subdir": add_chapter_subdir, + "add_language_to_chapter_name": add_language_to_chapter_name, + } + type(self).init_args = payload + type(self).calls.append(payload) + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Accept image writes without side effects.""" + del image_data, index + + def skip_image(self, index: PageIndex) -> bool: + """Return false so page processing would continue.""" + del index + return False + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + +class RecordingRawExporter(RecordingExporter): + """Raw exporter marker for output-selection tests.""" + + +class RecordingPdfExporter(RecordingExporter): + """PDF exporter marker for output-selection tests.""" + + +class RecordingCbzExporter(RecordingExporter): + """CBZ exporter marker for output-selection tests.""" + + +class RecordingDownloadRuntime: + """Download runtime test double capturing constructor and download calls.""" + + init_args: ClassVar[dict[str, object] | None] = None + download_args: ClassVar[dict[str, object] | None] = None + summary: ClassVar[DownloadSummary | None] = DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + @classmethod + def reset(cls) -> None: + """Reset captured runtime state.""" + cls.init_args = None + cls.download_args = None + + def __init__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool = False, + *, + destination: str = "mloader_downloads", + output_format: EffectiveOutputFormat = "cbz", + capture_api_dir: str | None = None, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + resume: bool = True, + manifest_reset: bool = False, + cover_format: CoverFormat = "png", + ) -> None: + """Record initialization arguments for assertions.""" + type(self).init_args = { + "exporter_factory": exporter, + "quality": quality, + "split": split, + "meta": meta, + "cover": cover, + "cover_format": cover_format, + "destination": destination, + "output_format": output_format, + "capture_api_dir": capture_api_dir, + "filename_style": filename_style, + "rename_existing_filenames": rename_existing_filenames, + "resume": resume, + "manifest_reset": manifest_reset, + } + + def download( + self, + *, + title_ids: set[int] | frozenset[int] | None = None, + chapter_numbers: set[int] | frozenset[int] | None = None, + chapter_ids: set[int] | frozenset[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary | None: + """Record download call keyword arguments for assertions.""" + type(self).download_args = { + "title_ids": title_ids, + "chapter_numbers": chapter_numbers, + "chapter_ids": chapter_ids, + "min_chapter": min_chapter, + "max_chapter": max_chapter, + "last_chapter": last_chapter, + } + return type(self).summary + + +class ApplicationRecordingDownloadRuntime(RecordingDownloadRuntime): + """Runtime double preserving application use-case summary expectations.""" + + summary: ClassVar[DownloadSummary | None] = DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=0, + failed_chapter_ids=(), + ) + + +class RuntimeFailingDownloadRuntime(RecordingDownloadRuntime): + """Runtime double that raises a generic failure.""" + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise a runtime error to exercise CLI exception handling.""" + del kwargs + raise RuntimeError("boom") + + +class SubscriptionRequiredDownloadRuntime(RecordingDownloadRuntime): + """Runtime double that raises a subscription-required error.""" + + message: ClassVar[str] = "A MAX subscription is required to download this chapter." + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise a subscription-required error to test CLI messaging.""" + del kwargs + raise SubscriptionRequiredError(type(self).message) + + +class ShortSubscriptionRequiredDownloadRuntime(SubscriptionRequiredDownloadRuntime): + """Runtime double using the concise subscription message expected by discovery tests.""" + + message: ClassVar[str] = "subscription required" + + +class RequestErrorDownloadRuntime(RecordingDownloadRuntime): + """Runtime double that raises request-layer failures.""" + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise request exception to verify external-failure mapping.""" + del kwargs + raise requests.RequestException("network down") + + +class PartialFailureDownloadRuntime(RecordingDownloadRuntime): + """Runtime double returning a failed chapter summary.""" + + summary: ClassVar[DownloadSummary | None] = DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=2, + failed_chapter_ids=DEFAULT_FAILED_CHAPTER_IDS, + ) + + +class SinglePartialFailureDownloadRuntime(RecordingDownloadRuntime): + """Runtime double returning one failed chapter summary for discovery mode tests.""" + + summary: ClassVar[DownloadSummary | None] = DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=1, + failed_chapter_ids=(123,), + ) + + +class InterruptedDownloadRuntime(RecordingDownloadRuntime): + """Runtime double raising interrupt wrapper with partial summary.""" + + interrupted_summary: ClassVar[DownloadSummary] = DownloadSummary( + downloaded=1, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(DEFAULT_INTERRUPTED_CHAPTER_ID,), + ) + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise downloader interrupt error containing partial run summary.""" + del kwargs + raise DownloadInterruptedError(type(self).interrupted_summary) + + +class APIResponseErrorDownloadRuntime(RecordingDownloadRuntime): + """Runtime double that raises MangaPlus payload validation errors.""" + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise APIResponseError for application external-dependency mapping tests.""" + del kwargs + raise APIResponseError("MangaPlus API returned no manga_viewer payload.") + + +class RequestFailingDownloadRuntime(RecordingDownloadRuntime): + """Runtime double that raises a generic request error for application tests.""" + + def download(self, **kwargs: object) -> DownloadSummary | None: + """Raise request exception for external-dependency mapping tests.""" + del kwargs + raise requests.RequestException("network") + + +class ApplicationInterruptedDownloadRuntime(InterruptedDownloadRuntime): + """Runtime double raising the application-level interrupted summary.""" + + interrupted_summary: ClassVar[DownloadSummary] = DownloadSummary( + downloaded=3, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(77,), + ) + + +class NoneReturningDownloadRuntime(RecordingDownloadRuntime): + """Runtime double returning no summary.""" + + summary: ClassVar[DownloadSummary | None] = None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..804e4ee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration shared by all test modules.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/downloader_helpers.py b/tests/downloader_helpers.py new file mode 100644 index 0000000..4b224c4 --- /dev/null +++ b/tests/downloader_helpers.py @@ -0,0 +1,373 @@ +"""Shared test doubles and DTO builders for downloader tests.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path + +from mloader.constants import PageType +from mloader.domain.manga import Chapter, ChapterGroup, LastPage, MangaPage, MangaViewer, Title +from mloader.domain.manga import TitleDetail, ViewerPage +from mloader.domain.manga import TitleTag +from mloader.domain.planning import DownloadPlan, TitleDownloadPlan +from mloader.domain.requests import CoverFormat, EffectiveOutputFormat, FilenameStyle +from mloader.manga_loader.download_execution import ( + DownloadExecutionContext, + DownloadExecutionService, +) +from mloader.manga_loader.download_services import DownloadServices +from mloader.manga_loader.manifest import TitleDownloadManifest +from mloader.manga_loader.page_export import PageImageService +from mloader.manga_loader.run_report import RunReport +from mloader.manga_loader.title_download import ManifestFactory +from mloader.types import ChapterLike, ExporterLike, PageIndex, ResponseLike, SessionLike, TitleLike + + +class DummyResponse(ResponseLike): + """Simple HTTP response test double with status tracking.""" + + def __init__(self, content: bytes = b"data") -> None: + """Store the payload and initialize status tracking.""" + self.content = content + self.status_checked = False + + def raise_for_status(self) -> None: + """Record that status validation was executed.""" + self.status_checked = True + + +class DummySession(SessionLike): + """Simple HTTP session test double collecting requested URLs.""" + + headers: dict[str, str] + + def __init__(self, response: DummyResponse) -> None: + """Initialize session with a fixed response object.""" + self.headers = {} + self.response = response + self.calls: list[str] = [] + self.adapters: dict[str, object] = {} + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> DummyResponse: + """Record URL requests and return the configured response.""" + del params, timeout + self.calls.append(url) + return self.response + + def mount(self, prefix: str, adapter: object) -> None: + """Record transport adapter configuration.""" + self.adapters[prefix] = adapter + + +class DummyPageImageService(PageImageService): + """Deterministic page-image service for execution-service tests.""" + + @staticmethod + def download_image( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + ) -> bytes: + """Return URL-derived bytes without touching network transport.""" + del session, request_timeout + return f"img:{url}".encode("utf-8") + + @staticmethod + def decrypt_image( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + encryption_hex: str, + ) -> bytearray: + """Return URL/key-derived bytes for encrypted-page tests.""" + del session, request_timeout + return bytearray(f"dec:{url}:{encryption_hex}".encode("utf-8")) + + +class NullExporter: + """Exporter test double with no filesystem side effects.""" + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Accept image writes without side effects.""" + del image_data, index + + def skip_image(self, index: PageIndex) -> bool: + """Return false so page processing continues by default.""" + del index + return False + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + +class NullExporterFactory: + """Exporter factory test double carrying the destination keyword payload.""" + + def __init__(self, destination: str) -> None: + """Store destination in the same shape used by concrete exporter classes.""" + self.keywords = {"destination": destination} + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + """Return a no-op exporter instance.""" + del title, chapter, next_chapter + return NullExporter() + + +def _build_execution_service( + *, + destination: str, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + output_format: EffectiveOutputFormat = "pdf", + meta: bool = False, + cover: bool = False, + cover_format: CoverFormat = "png", + resume: bool = True, + manifest_reset: bool = False, + response: DummyResponse | None = None, + manifest_factory: ManifestFactory = TitleDownloadManifest, + services: DownloadServices | None = None, +) -> DownloadExecutionService: + """Build a concrete execution service with deterministic test transport.""" + session = DummySession(response or DummyResponse(content=b"default")) + return DownloadExecutionService( + DownloadExecutionContext( + exporter=NullExporterFactory(destination), + destination=destination, + output_format=output_format, + session=session, + request_timeout=(0.1, 0.1), + cover=cover, + meta=meta, + resume=resume, + manifest_reset=manifest_reset, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + cover_format=cover_format, + services=services or DownloadServices.defaults(), + prepare_download_plan=lambda *_args: DownloadPlan(title_plans=()), + load_pages=lambda chapter_id: viewer(chapter_id=int(chapter_id)), + clear_api_caches_for_run=lambda: None, + clear_api_caches_for_title=lambda _title_id, _chapter_ids: None, + manifest_factory=manifest_factory, + ) + ) + + +def dummy_downloader( + destination: str = "/tmp/out", + *, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + output_format: EffectiveOutputFormat = "pdf", + meta: bool = False, + cover: bool = False, + cover_format: CoverFormat = "png", + resume: bool = True, + manifest_reset: bool = False, + response: DummyResponse | None = None, + manifest_factory: ManifestFactory = TitleDownloadManifest, +) -> DownloadExecutionService: + """Build an execution-service harness overriding page-image side effects.""" + return _build_execution_service( + destination=destination, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + output_format=output_format, + meta=meta, + cover=cover, + cover_format=cover_format, + resume=resume, + manifest_reset=manifest_reset, + response=response, + manifest_factory=manifest_factory, + services=replace( + DownloadServices.defaults(), + page_image_service=DummyPageImageService, + ), + ) + + +def full_downloader( + destination: str = "/tmp/out", + *, + filename_style: FilenameStyle = "legacy", + rename_existing_filenames: bool = False, + output_format: EffectiveOutputFormat = "pdf", + meta: bool = False, + cover: bool = False, + cover_format: CoverFormat = "png", + resume: bool = True, + manifest_reset: bool = False, + response: DummyResponse | None = None, + manifest_factory: ManifestFactory = TitleDownloadManifest, +) -> DownloadExecutionService: + """Build an execution-service harness using real internals where practical.""" + return _build_execution_service( + destination=destination, + filename_style=filename_style, + rename_existing_filenames=rename_existing_filenames, + output_format=output_format, + meta=meta, + cover=cover, + cover_format=cover_format, + resume=resume, + manifest_reset=manifest_reset, + response=response, + manifest_factory=manifest_factory, + ) + + +def chapter( + chapter_id: int, + name: str, + sub_title: str = "sub", + *, + title_id: int = 10, + thumbnail_url: str = "", + start_timestamp: int = 0, +) -> Chapter: + """Build a minimal chapter DTO.""" + return Chapter( + title_id=title_id, + chapter_id=chapter_id, + name=name, + sub_title=sub_title, + thumbnail_url=thumbnail_url, + start_timestamp=start_timestamp, + ) + + +def group(chapters: list[Chapter]) -> ChapterGroup: + """Build a chapter group wrapper used by title details.""" + return ChapterGroup( + first_chapters=tuple(chapters), + mid_chapters=(), + last_chapters=(), + ) + + +def title_detail( + *, + title_id: int = 10, + name: str = "My Manga", + author: str = "A", + chapters: list[Chapter] | None = None, + title_image_url: str = "", + portrait_image_url: str = "", + landscape_image_url: str = "", + non_appearance_info: str = "n/a", + number_of_views: int = 0, + overview: str = "overview", + tags: tuple[TitleTag, ...] = (), + web_url: str = "", +) -> TitleDetail: + """Build a title-detail DTO for downloader tests.""" + return TitleDetail( + title=Title( + title_id=title_id, + name=name, + author=author, + portrait_image_url=portrait_image_url, + landscape_image_url=landscape_image_url, + language=0, + overview=overview, + tags=tags, + web_url=web_url, + ), + title_image_url=title_image_url, + overview=overview, + non_appearance_info=non_appearance_info, + number_of_views=number_of_views, + chapter_groups=(group(chapters or []),), + ) + + +def title_plan( + *, + title_id: int = 10, + name: str = "My Manga", + author: str = "A", + chapter_ids: set[int] | None = None, +) -> TitleDownloadPlan: + """Build a title download plan for orchestration tests.""" + chapters = [ + chapter(chapter_id, f"#{chapter_id}", title_id=title_id) + for chapter_id in sorted(chapter_ids or {1}) + ] + detail = title_detail(title_id=title_id, name=name, author=author, chapters=chapters) + return TitleDownloadPlan(title_detail=detail, selected_chapters=tuple(chapters)) + + +def download_plan(plan: TitleDownloadPlan | None = None) -> DownloadPlan: + """Build a one-title download plan.""" + return DownloadPlan(title_plans=(plan or title_plan(),)) + + +def manga_page( + image_url: str, + *, + page_type: PageType = PageType.SINGLE, + encryption_key: str = "", +) -> MangaPage: + """Build a manga-page DTO.""" + return MangaPage( + image_url=image_url, + width=1, + height=1, + page_type=page_type.value, + encryption_key=encryption_key, + ) + + +def viewer( + *, + title_id: int = 10, + chapter_id: int = 10, + chapter_name: str = "#1", + current_chapter: Chapter | None = None, + pages: tuple[MangaPage, ...] = (), + next_chapter: Chapter | None = None, + include_last_page: bool = True, +) -> MangaViewer: + """Build a manga-viewer DTO with optional downloadable pages and terminal metadata.""" + current = current_chapter or chapter(chapter_id, chapter_name, "Sub", title_id=title_id) + viewer_pages = tuple(ViewerPage(manga_page=page, last_page=None) for page in pages) + if include_last_page: + viewer_pages = ( + *viewer_pages, + ViewerPage( + manga_page=None, + last_page=LastPage(current_chapter=current, next_chapter=next_chapter), + ), + ) + return MangaViewer( + title_id=title_id, + chapter_id=chapter_id, + title_name=f"Title {title_id}", + chapter_name=chapter_name, + chapters=(current,), + pages=viewer_pages, + ) + + +def run_report() -> RunReport: + """Return mutable run report instance matching downloader internals.""" + return RunReport() + + +def title_export_dir(tmp_path: Path, title_name: str = "My Manga") -> Path: + """Return the conventional title export directory under ``tmp_path``.""" + return tmp_path / title_name diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json new file mode 100644 index 0000000..b449fe3 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json @@ -0,0 +1,17 @@ +{ + "captured_at_utc": "2026-02-13T22:09:00.680623+00:00", + "endpoint": "title_detailV3", + "identifier": "100010", + "params": { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "title_id": 100010 + }, + "parsed_payload_file": "0001_title_detailV3_100010.response.json", + "payload_sha256": "9d73849e8172c3bce4309f11cb540f531805d61023651744872af6ddef35608b", + "payload_size_bytes": 50999, + "raw_payload_file": "0001_title_detailV3_100010.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/title_detailV3" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb new file mode 100644 index 0000000..b5b8c70 Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb differ diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json new file mode 100644 index 0000000..4550f6a --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json @@ -0,0 +1,2187 @@ +{ + "success": { + "title_detail_view": { + "chapter_list_group": [ + { + "chapter_numbers": "50", + "first_chapter_list": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "mid_chapter_list": [ + { + "already_viewed": true, + "chapter_id": 1000313, + "end_timestamp": 2145884400, + "name": "#004", + "start_timestamp": 1547996400, + "sub_title": "Z=4: Pure White Seashells", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000313/chapter_thumbnail/1693.webp?hash=l0MeH1w8JRGi_3c-58R5EQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000314, + "end_timestamp": 2145884400, + "name": "#005", + "start_timestamp": 1547996400, + "sub_title": "Z=5: Yuzuriha", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000314/chapter_thumbnail/1696.webp?hash=snyg3Xasnralvi4WqK2bjw&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000315, + "end_timestamp": 2145884400, + "name": "#006", + "start_timestamp": 1547996400, + "sub_title": "Z=6: Taiju vs. Tsukasa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000315/chapter_thumbnail/1699.webp?hash=zT4Tnjjmr_ukPo7p5Nserg&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000316, + "end_timestamp": 2145884400, + "name": "#007", + "start_timestamp": 1640962800, + "sub_title": "Z=7: The Gunpowder Adventure", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000316/chapter_thumbnail/1702.webp?hash=xXVp2f_vzU28UY3GWIMUiA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012263, + "end_timestamp": 2145884400, + "name": "#008", + "start_timestamp": 1640962800, + "sub_title": "Z=8: Raise the Smoke Signal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012263/chapter_thumbnail/201808.webp?hash=zttiXU1fZ7tg7erIhExpAg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012264, + "end_timestamp": 2145884400, + "name": "#009", + "start_timestamp": 1640962800, + "sub_title": "Z=9: Senku Vs. Tsukasa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012264/chapter_thumbnail/201811.webp?hash=npf9EWyqOGjopInMFnhJ1g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012265, + "end_timestamp": 2145884400, + "name": "#010", + "start_timestamp": 1640962800, + "sub_title": "Z=10: Student Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012265/chapter_thumbnail/201814.webp?hash=6CZzFncusSiRzArdUv7T7w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012266, + "end_timestamp": 2145884400, + "name": "#011", + "start_timestamp": 1640962800, + "sub_title": "Z=11: Weapon Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012266/chapter_thumbnail/201817.webp?hash=ijZbAeSWddBCSsU1noADoA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012267, + "end_timestamp": 2145884400, + "name": "#012", + "start_timestamp": 1640962800, + "sub_title": "Z=12: Epilogue Of Prologue (End of Part 0)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012267/chapter_thumbnail/201820.webp?hash=1QT9hCZ87lFmYO5oVfZlZw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012268, + "end_timestamp": 2145884400, + "name": "#013", + "start_timestamp": 1640962800, + "sub_title": "Z=13: Part 1: Stone World-The Beginning", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012268/chapter_thumbnail/201823.webp?hash=s4346hVbLbZgtoJXhfmQqg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012269, + "end_timestamp": 2145884400, + "name": "#014", + "start_timestamp": 1640962800, + "sub_title": "Z=14: Those Who Have Faith", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012269/chapter_thumbnail/201826.webp?hash=sSqahMCL0QzZY2teIIsIQg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012270, + "end_timestamp": 2145884400, + "name": "#015", + "start_timestamp": 1640962800, + "sub_title": "Z=15: Two Kingdoms Of The Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012270/chapter_thumbnail/201829.webp?hash=WjT6eA5ASPntjxU7n3buBg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012271, + "end_timestamp": 2145884400, + "name": "#016", + "start_timestamp": 1640962800, + "sub_title": "Z=16: Kohaku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012271/chapter_thumbnail/201832.webp?hash=CpLmHACVngEP21smaXk7nQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012272, + "end_timestamp": 2145884400, + "name": "#017", + "start_timestamp": 1640962800, + "sub_title": "Z=17: Nasty Looks", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012272/chapter_thumbnail/201835.webp?hash=sh7_pYx7HVnNyuyw-XOvDA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012273, + "end_timestamp": 2145884400, + "name": "#018", + "start_timestamp": 1640962800, + "sub_title": "Z=18: Sorcery Showdown", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012273/chapter_thumbnail/201838.webp?hash=8TNQSxfwBhAi2tQzGrul7g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012274, + "end_timestamp": 2145884400, + "name": "#019", + "start_timestamp": 1640962800, + "sub_title": "Z=19: Two Million Years Of Being", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012274/chapter_thumbnail/201841.webp?hash=orDvfmkoHneRUeQCASfNMA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012275, + "end_timestamp": 2145884400, + "name": "#020", + "start_timestamp": 1640962800, + "sub_title": "Z=20: Stone Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012275/chapter_thumbnail/201844.webp?hash=WKnCIXNZaBm_hRWNt9yo3g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012276, + "end_timestamp": 2145884400, + "name": "#021", + "start_timestamp": 1640962800, + "sub_title": "Z=21: Dawn of Iron", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012276/chapter_thumbnail/201847.webp?hash=v9kuKnMKfSTGmSoC4Oh7_Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012277, + "end_timestamp": 2145884400, + "name": "#022", + "start_timestamp": 1640962800, + "sub_title": "Z=22: Survival Gourmet", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012277/chapter_thumbnail/201850.webp?hash=TK2RNXFaMIz4S9V-T-w0kg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012278, + "end_timestamp": 2145884400, + "name": "#023", + "start_timestamp": 1640962800, + "sub_title": "Z=23: The Smooth Talker", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012278/chapter_thumbnail/201853.webp?hash=K1x2L9ZmpHmkAWGGBB8HUg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012279, + "end_timestamp": 2145884400, + "name": "#024", + "start_timestamp": 1640962800, + "sub_title": "Z=24: Lightning Speed!!", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012279/chapter_thumbnail/201856.webp?hash=2KE2phCOLFBoXkRn3O-azQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012280, + "end_timestamp": 2145884400, + "name": "#025", + "start_timestamp": 1640962800, + "sub_title": "Z=25: By These Hands, The Light Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012280/chapter_thumbnail/201859.webp?hash=EJC23_vAHOfFXxoE_8xMSA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012281, + "end_timestamp": 2145884400, + "name": "#026", + "start_timestamp": 1640962800, + "sub_title": "Z=26: A Shallow Alliance", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012281/chapter_thumbnail/201862.webp?hash=yyxKO8Zjt5W3JWMHpNUg5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012282, + "end_timestamp": 2145884400, + "name": "#027", + "start_timestamp": 1640962800, + "sub_title": "Z=27: A Certain Scientist's Wish", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012282/chapter_thumbnail/201865.webp?hash=MdRmcfccZyZoSdKkMIomcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012283, + "end_timestamp": 2145884400, + "name": "#028", + "start_timestamp": 1640962800, + "sub_title": "Z=28: Clear World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012283/chapter_thumbnail/201868.webp?hash=paVUp29ppUUeKBxKxNtH6A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012284, + "end_timestamp": 2145884400, + "name": "#029", + "start_timestamp": 1640962800, + "sub_title": "Z=29: Senku's Lab", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012284/chapter_thumbnail/201871.webp?hash=X7ZipXRw3a9Sz_aOFjM7BQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012285, + "end_timestamp": 2145884400, + "name": "#030", + "start_timestamp": 1640962800, + "sub_title": "Z=30: Death Green", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012285/chapter_thumbnail/201874.webp?hash=RH9UOm5XStbQ8Zo5M9TMrg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012286, + "end_timestamp": 2145884400, + "name": "#031", + "start_timestamp": 1640962800, + "sub_title": "Z=31: Friends Have Each Other's Backs", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012286/chapter_thumbnail/201877.webp?hash=hUIXpe6YdDfXwbPLHKr-ug&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012287, + "end_timestamp": 2145884400, + "name": "#032", + "start_timestamp": 1640962800, + "sub_title": "Z=32: Brains & Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012287/chapter_thumbnail/201880.webp?hash=hzyMRERQpA_0AyF1xgm9YA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012288, + "end_timestamp": 2145884400, + "name": "#033", + "start_timestamp": 1640962800, + "sub_title": "Z=33: Baaad Chemicals", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012288/chapter_thumbnail/201883.webp?hash=b4e2A2KdQSpNl_JTeNjwCg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012289, + "end_timestamp": 2145884400, + "name": "#034", + "start_timestamp": 1640962800, + "sub_title": "Z=34: Sneaky Grand Bout Strategy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012289/chapter_thumbnail/201886.webp?hash=Mok_ZJtvH7CYR4kZU59gbQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012290, + "end_timestamp": 2145884400, + "name": "#035", + "start_timestamp": 1640962800, + "sub_title": "Z=35: The Masked Warrior", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012290/chapter_thumbnail/201889.webp?hash=3W6ZpWLxvNZ7H2E7S_42Uw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012291, + "end_timestamp": 2145884400, + "name": "#036", + "start_timestamp": 1640962800, + "sub_title": "Z=36: Kinro And Ginro", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012291/chapter_thumbnail/201892.webp?hash=mEzUiizwNru882Ikcp4p9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012292, + "end_timestamp": 2145884400, + "name": "#037", + "start_timestamp": 1640962800, + "sub_title": "Z=37: Science-User Chrome", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012292/chapter_thumbnail/201895.webp?hash=L6OJ5f_Qt9S3LRlBKzMNqA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012293, + "end_timestamp": 2145884400, + "name": "#038", + "start_timestamp": 1640962800, + "sub_title": "Z=38: Master Of Flame", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012293/chapter_thumbnail/201898.webp?hash=dkWbMBVCzracvD8iu1a72A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012294, + "end_timestamp": 2145884400, + "name": "#039", + "start_timestamp": 1640962800, + "sub_title": "Z=39: And The Winner Is...", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012294/chapter_thumbnail/201901.webp?hash=oFE65a9KlBpFl0DN8PGS7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012295, + "end_timestamp": 2145884400, + "name": "#040", + "start_timestamp": 1640962800, + "sub_title": "Z=40: Two Million Years In The Making", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012295/chapter_thumbnail/201904.webp?hash=I_5pApl8wB0_6EpBzby8rA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012296, + "end_timestamp": 2145884400, + "name": "#041", + "start_timestamp": 1640962800, + "sub_title": "Z=41: Doctor Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012296/chapter_thumbnail/201907.webp?hash=7eaqtV3IwXOw5vDUc-oWew&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012297, + "end_timestamp": 2145884400, + "name": "#042", + "start_timestamp": 1640962800, + "sub_title": "Z=42: Tale For The Ages", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012297/chapter_thumbnail/201910.webp?hash=auXaCozKyPxPb61azRvcNw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012298, + "end_timestamp": 2145884400, + "name": "#043", + "start_timestamp": 1640962800, + "sub_title": "Z=43: Humanity's Final Six", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012298/chapter_thumbnail/201913.webp?hash=MbWRqyFvogAAVrhsCX82IQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012299, + "end_timestamp": 2145884400, + "name": "#044", + "start_timestamp": 1640962800, + "sub_title": "Z=44: One Hundred Nights, One Thousand Skies", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012299/chapter_thumbnail/201916.webp?hash=eWHkso7FUn2JOkjiYSaytA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012300, + "end_timestamp": 2145884400, + "name": "#045", + "start_timestamp": 1640962800, + "sub_title": "Z=45: Epilogue Of Part 1 (End Of Part 1)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012300/chapter_thumbnail/201919.webp?hash=cAeDPeVgJQIJSXHWxMALAw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012301, + "end_timestamp": 2145884400, + "name": "#046", + "start_timestamp": 1640962800, + "sub_title": "Z=46: Stone Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012301/chapter_thumbnail/201922.webp?hash=1xE9sxvdM30G32GWocR3Ag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012302, + "end_timestamp": 2145884400, + "name": "#047", + "start_timestamp": 1640962800, + "sub_title": "Z=47: Science Vs. Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012302/chapter_thumbnail/201925.webp?hash=WQUWB3miiCTCqEg3_QhcTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012303, + "end_timestamp": 2145884400, + "name": "#048", + "start_timestamp": 1640962800, + "sub_title": "Z=48: Blades Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012303/chapter_thumbnail/201928.webp?hash=ywvuUgCrChAHHW3E2c9n7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012304, + "end_timestamp": 2145884400, + "name": "#049", + "start_timestamp": 1640962800, + "sub_title": "Z=49: To The Present", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012304/chapter_thumbnail/201931.webp?hash=7U0HoBXN1bihVYop5OWM3Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012305, + "end_timestamp": 2145884400, + "name": "#050", + "start_timestamp": 1640962800, + "sub_title": "Z=50: Humanity's Greatest Weapon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012305/chapter_thumbnail/201934.webp?hash=vFwz1urY0PNezwlZqu7nPA&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "100", + "mid_chapter_list": [ + { + "chapter_id": 1012306, + "end_timestamp": 2145884400, + "name": "#051", + "start_timestamp": 1640962800, + "sub_title": "Z=51: Sweets For The Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012306/chapter_thumbnail/201937.webp?hash=kk-_5wWmMWgAGFvpapNkaQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012307, + "end_timestamp": 2145884400, + "name": "#052", + "start_timestamp": 1640962800, + "sub_title": "Z=52: Age Of Energy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012307/chapter_thumbnail/201940.webp?hash=kFGantaUp9Hh6nKMNd5QeA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012308, + "end_timestamp": 2145884400, + "name": "#053", + "start_timestamp": 1640962800, + "sub_title": "Z=53: Hard Knocks Crafting Club", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012308/chapter_thumbnail/201943.webp?hash=L0zjXkLHeP1ZIJc_JVMCuw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012309, + "end_timestamp": 2145884400, + "name": "#054", + "start_timestamp": 1640962800, + "sub_title": "Z=54: Flickering Blue Jewel", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012309/chapter_thumbnail/201946.webp?hash=MwGgsxmzIZMiNYFrjzCLag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012310, + "end_timestamp": 2145884400, + "name": "#055", + "start_timestamp": 1640962800, + "sub_title": "Z=55: Treasure Dungeon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012310/chapter_thumbnail/201949.webp?hash=T0DOY1HduVaYJgOHkRLlMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012311, + "end_timestamp": 2145884400, + "name": "#056", + "start_timestamp": 1640962800, + "sub_title": "Z=56: The Treasure", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012311/chapter_thumbnail/201952.webp?hash=W5y4XiNWhFLo1oPho-tHfw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012312, + "end_timestamp": 2145884400, + "name": "#057", + "start_timestamp": 1640962800, + "sub_title": "Z=57: Heat Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012312/chapter_thumbnail/201955.webp?hash=wMRcd0i9PQoWBJpae6NJrw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012313, + "end_timestamp": 2145884400, + "name": "#058", + "start_timestamp": 1640962800, + "sub_title": "Z=58: Wave Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012313/chapter_thumbnail/201958.webp?hash=Gu3wSWazqWAfGdhseg_e3Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012314, + "end_timestamp": 2145884400, + "name": "#059", + "start_timestamp": 1640962800, + "sub_title": "Z=59: Voices from Here To Infinity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012314/chapter_thumbnail/201961.webp?hash=Xx8QRMLpxEmhG8_PTQFiIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012315, + "end_timestamp": 2145884400, + "name": "#060", + "start_timestamp": 1640962800, + "sub_title": "Z=60: Angel's Song, Devil's Whisper", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012315/chapter_thumbnail/201964.webp?hash=bCb_5RYQtB2WvJW1pVruQA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012316, + "end_timestamp": 2145884400, + "name": "#061", + "start_timestamp": 1640962800, + "sub_title": "Z=61: Stone Wars Begin", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012316/chapter_thumbnail/201967.webp?hash=Ykl0AMTFUJCHUdwauJMNQw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012317, + "end_timestamp": 2145884400, + "name": "#062", + "start_timestamp": 1640962800, + "sub_title": "Z=62: Double Chase", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012317/chapter_thumbnail/201970.webp?hash=9b3XnfO9nqpyWu_1IpwYTg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012318, + "end_timestamp": 2145884400, + "name": "#063", + "start_timestamp": 1640962800, + "sub_title": "Z=63: Information Warfare", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012318/chapter_thumbnail/201973.webp?hash=x0ZLgEGhVOQofLzMTyzhXQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012319, + "end_timestamp": 2145884400, + "name": "#064", + "start_timestamp": 1640962800, + "sub_title": "Z=64: Hotline", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012319/chapter_thumbnail/201976.webp?hash=TomITaHUTDv-C13li0OpXQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012320, + "end_timestamp": 2145884400, + "name": "#065", + "start_timestamp": 1640962800, + "sub_title": "Z=65: Call From The Dead", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012320/chapter_thumbnail/201979.webp?hash=iKLbYkXKiNeJCaixalxjOw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012321, + "end_timestamp": 2145884400, + "name": "#066", + "start_timestamp": 1640962800, + "sub_title": "Z=66: Liars And Truth-Tellers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012321/chapter_thumbnail/201982.webp?hash=bIpfeaIFsh9LsMpPt70N_g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012322, + "end_timestamp": 2145884400, + "name": "#067", + "start_timestamp": 1640962800, + "sub_title": "Z=67: Full Mobilization", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012322/chapter_thumbnail/201985.webp?hash=RRQh0wAgOMoyeJ1Hv15btQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012323, + "end_timestamp": 2145884400, + "name": "#068", + "start_timestamp": 1640962800, + "sub_title": "Z=68: Flames Of Revolution", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012323/chapter_thumbnail/201988.webp?hash=jMCMAeyHfVKexlHbiKL6cg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012324, + "end_timestamp": 2145884400, + "name": "#069", + "start_timestamp": 1640962800, + "sub_title": "Z=69: Steam Gorilla", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012324/chapter_thumbnail/201991.webp?hash=-Xs-bUQK7ASdjnRc7aJiwQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012325, + "end_timestamp": 2145884400, + "name": "#070", + "start_timestamp": 1640962800, + "sub_title": "Z=70: Paper Shield", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012325/chapter_thumbnail/201994.webp?hash=YeaVuHG-IJUmqsE6N6oTAQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012326, + "end_timestamp": 2145884400, + "name": "#071", + "start_timestamp": 1640962800, + "sub_title": "Z=71: Prison Break", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012326/chapter_thumbnail/201997.webp?hash=b0_wn--zDV5QjqgzahgG8Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012327, + "end_timestamp": 2145884400, + "name": "#072", + "start_timestamp": 1640962800, + "sub_title": "Z=72: Experience Points", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012327/chapter_thumbnail/202000.webp?hash=JildIlYAVAjDfBZHonqy-A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012328, + "end_timestamp": 2145884400, + "name": "#073", + "start_timestamp": 1640962800, + "sub_title": "Z=73: Top-Secret Mission", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012328/chapter_thumbnail/202003.webp?hash=p5UifDyCDOQ1dxRXn2ApXA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012329, + "end_timestamp": 2145884400, + "name": "#074", + "start_timestamp": 1640962800, + "sub_title": "Z=74: Fateful 20 Seconds", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012329/chapter_thumbnail/202006.webp?hash=lESOj-9g1VSSh86vW8a5qQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012330, + "end_timestamp": 2145884400, + "name": "#075", + "start_timestamp": 1640962800, + "sub_title": "Z=75: 20-Second Countdown", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012330/chapter_thumbnail/202009.webp?hash=Y23TqpsDKwX2xy1lKAoDlA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012331, + "end_timestamp": 2145884400, + "name": "#076", + "start_timestamp": 1640962800, + "sub_title": "Z=76: Final Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012331/chapter_thumbnail/202012.webp?hash=qNIN_X--fLZz8EhpgGr3jg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012332, + "end_timestamp": 2145884400, + "name": "#077", + "start_timestamp": 1640962800, + "sub_title": "Z=77: The Power Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012332/chapter_thumbnail/202015.webp?hash=L9YTYGRdTxRZUOa32oRudw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012333, + "end_timestamp": 2145884400, + "name": "#078", + "start_timestamp": 1640962800, + "sub_title": "Z=78: That Which Destroys Or Saves", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012333/chapter_thumbnail/202018.webp?hash=ZG2g4eSNYXar88JbbcRyTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012334, + "end_timestamp": 2145884400, + "name": "#079", + "start_timestamp": 1640962800, + "sub_title": "Z=79: For This Very Moment", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012334/chapter_thumbnail/202021.webp?hash=xN_0e7WT_KYPvJhv2yvVGg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012335, + "end_timestamp": 2145884400, + "name": "#080", + "start_timestamp": 1640962800, + "sub_title": "Z=80: Humanity's Strongest Tag Team", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012335/chapter_thumbnail/202024.webp?hash=spBBVjJgbmNdEtZZJNksmQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012336, + "end_timestamp": 2145884400, + "name": "#081", + "start_timestamp": 1640962800, + "sub_title": "Z=81: Fingertip", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012336/chapter_thumbnail/202027.webp?hash=8jwiKem9oJq2bJIdSdDWlQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012337, + "end_timestamp": 2145884400, + "name": "#082", + "start_timestamp": 1640962800, + "sub_title": "Z=82: Epilogue of Stone Wars (End Of Part 2)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012337/chapter_thumbnail/202030.webp?hash=GJB1fpuADj_439UW7AR7CQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012338, + "end_timestamp": 2145884400, + "name": "#083", + "start_timestamp": 1640962800, + "sub_title": "Z=83: Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012338/chapter_thumbnail/202033.webp?hash=KPE3gCW3MiAQVkYZOCNMqQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012339, + "end_timestamp": 2145884400, + "name": "#084", + "start_timestamp": 1640962800, + "sub_title": "Z=84: People Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012339/chapter_thumbnail/202036.webp?hash=ZsDIH3LeUOBywmIyVM_s9A&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001174, + "end_timestamp": 2145884400, + "name": "#085", + "start_timestamp": 1547996400, + "sub_title": "Z=85: Ultimate Resource", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001174/chapter_thumbnail/7153.webp?hash=5leStJ8t-KtjOAWePzUYVg&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001175, + "end_timestamp": 2145884400, + "name": "#086", + "start_timestamp": 1547996400, + "sub_title": "Z=86: Money", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001175/chapter_thumbnail/7156.webp?hash=km59KviyWF_R6bsSZ22KEA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001176, + "end_timestamp": 2145884400, + "name": "#087", + "start_timestamp": 1547996400, + "sub_title": "Z=87: Senku's Department Store", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001176/chapter_thumbnail/7159.webp?hash=yDayCqHw1Ya9qMovxnOYKA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001177, + "end_timestamp": 2145884400, + "name": "#088", + "start_timestamp": 1547996400, + "sub_title": "Z=88: Wings of Humanity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001177/chapter_thumbnail/7162.webp?hash=SxBMtTGq_5MtJIgwWwYMqA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001178, + "end_timestamp": 2145884400, + "name": "#089", + "start_timestamp": 1547996400, + "sub_title": "Z=89: Adventurers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001178/chapter_thumbnail/7165.webp?hash=hzZn2SyeCuucwQ5_r4jwfg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001179, + "end_timestamp": 2145884400, + "name": "#090", + "start_timestamp": 1548014400, + "sub_title": "Z=90: New World Map", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001179/chapter_thumbnail/8710.webp?hash=l1hFY7QTzaGma21jdT8sTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001180, + "end_timestamp": 2145884400, + "name": "#091", + "start_timestamp": 1548619200, + "sub_title": "Z=91: Need Bread? Start with Wheat", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001180/chapter_thumbnail/11296.webp?hash=g-ser8a-1LVTU6VafDejjQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001181, + "end_timestamp": 2145884400, + "name": "#092", + "start_timestamp": 1549224000, + "sub_title": "Z=92: Desire Is Noble", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001181/chapter_thumbnail/11575.webp?hash=dpV9-X66oGFH7DAdxnBi2Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001182, + "end_timestamp": 2145884400, + "name": "#093", + "start_timestamp": 1549656000, + "sub_title": "Z=93: The First Shot Is Yours", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001182/chapter_thumbnail/11926.webp?hash=MaTrdW985w20avwcHn84Sw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001183, + "end_timestamp": 2145884400, + "name": "#094", + "start_timestamp": 1550433600, + "sub_title": "Z=94: The Scent of Black Gold", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001183/chapter_thumbnail/12154.webp?hash=ihlB3HFrfHm1cQm1RJq6QA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001184, + "end_timestamp": 2145884400, + "name": "#095", + "start_timestamp": 1551038400, + "sub_title": "Z=95: First Contact", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001184/chapter_thumbnail/15229.webp?hash=MBx1Zr37PuxVV8wfBZ0NxA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001622, + "end_timestamp": 2145884400, + "name": "#096", + "start_timestamp": 1551643200, + "sub_title": "Z=96: Eye of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001622/chapter_thumbnail/15541.webp?hash=SF27o7qY6G8UmcDN_82F3A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001623, + "end_timestamp": 2145884400, + "name": "#097", + "start_timestamp": 1552248000, + "sub_title": "Z=97: The Joy of Leadership", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001623/chapter_thumbnail/16819.webp?hash=tCsc7h4-AAsXah1EO0k6xQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001624, + "end_timestamp": 2145884400, + "name": "#098", + "start_timestamp": 1552852800, + "sub_title": "Z=98: Ryusui", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001624/chapter_thumbnail/17041.webp?hash=nlXyLE2ivL8S5XPbJISlxA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001625, + "end_timestamp": 2145884400, + "name": "#099", + "start_timestamp": 1553457600, + "sub_title": "Z=99: Kingdom of Science Photo Journal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001625/chapter_thumbnail/19561.webp?hash=5TG3Wwdrf6nRGCKosKeCdw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001759, + "end_timestamp": 2145884400, + "name": "#100", + "start_timestamp": 1554667200, + "sub_title": "Z=100: Origin of the 100 Tales", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001759/chapter_thumbnail/20542.webp?hash=Gx91y2sMOOcmADqupab2bA&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "150", + "mid_chapter_list": [ + { + "chapter_id": 1001760, + "end_timestamp": 2145884400, + "name": "#101", + "start_timestamp": 1555272000, + "sub_title": "Z=101: Treasure Chest", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001760/chapter_thumbnail/23479.webp?hash=7ACiQ_XM_3iigEqmAN83Vg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001761, + "end_timestamp": 2145884400, + "name": "#102", + "start_timestamp": 1555876800, + "sub_title": "Z=102: Perseus, Ship of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001761/chapter_thumbnail/25188.webp?hash=sRcTiTsipye5jzHBIH9ggA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001762, + "end_timestamp": 2145884400, + "name": "#103", + "start_timestamp": 1556308800, + "sub_title": "Z=103: Light of Hope and Despair", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001762/chapter_thumbnail/26955.webp?hash=TpyHAai5sAXNikUwCyAz9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001763, + "end_timestamp": 2145884400, + "name": "#104", + "start_timestamp": 1557691200, + "sub_title": "Z=104: Men of Forensics", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001763/chapter_thumbnail/27243.webp?hash=o_n9aRs6HhrLV0GFKk70yA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001935, + "end_timestamp": 2145884400, + "name": "#105", + "start_timestamp": 1558296000, + "sub_title": "Z=105: The Island's Greatest Beauty", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001935/chapter_thumbnail/28524.webp?hash=Sfz2diOzS_mBJp3I0d7fPA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001936, + "end_timestamp": 2145884400, + "name": "#106", + "start_timestamp": 1558900800, + "sub_title": "Z=106: The Secret of Petrification", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001936/chapter_thumbnail/28647.webp?hash=1IW0HlLoceqOnZqDw4xm8Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001937, + "end_timestamp": 2145884400, + "name": "#107", + "start_timestamp": 1559505600, + "sub_title": "Z=107: Ace in the Hole on the Ship of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001937/chapter_thumbnail/33180.webp?hash=qgYw8j182p9ThyelCIhwIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002366, + "end_timestamp": 2145884400, + "name": "#108", + "start_timestamp": 1560110400, + "sub_title": "Z=108: Double Ace in the Hole", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002366/chapter_thumbnail/33507.webp?hash=oG_F4TS_Hi8VuulCi8fmJg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002367, + "end_timestamp": 2145884400, + "name": "#109", + "start_timestamp": 1560715200, + "sub_title": "Z=109: Great Escape", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002367/chapter_thumbnail/33774.webp?hash=zGmuYM-S1YEWu9iEH6DvgA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002368, + "end_timestamp": 2145884400, + "name": "#110", + "start_timestamp": 1561320000, + "sub_title": "Z=110: Beauty Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002368/chapter_thumbnail/34014.webp?hash=9BNXFsIoK2MnlNxCKt8J5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002369, + "end_timestamp": 2145884400, + "name": "#111", + "start_timestamp": 1561924800, + "sub_title": "Z=111: Science Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002369/chapter_thumbnail/35478.webp?hash=MYQoNeBnly0b6EHJUh7O5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003161, + "end_timestamp": 2145884400, + "name": "#112", + "start_timestamp": 1562529600, + "sub_title": "Z=112: King of Three Dimensions", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003161/chapter_thumbnail/36969.webp?hash=IlBVZUF94jtoFMjuin9WZw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003162, + "end_timestamp": 2145884400, + "name": "#113", + "start_timestamp": 1562961600, + "sub_title": "Z=113: Cryptography Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003162/chapter_thumbnail/37269.webp?hash=M2YSk_J5ReByKaUCF_3neA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003163, + "end_timestamp": 2145884400, + "name": "#114", + "start_timestamp": 1563739200, + "sub_title": "Z=114: Silently, Science Pierces the Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003163/chapter_thumbnail/37581.webp?hash=L-NuybAcFa_WP1umOrtIFQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003164, + "end_timestamp": 2145884400, + "name": "#115", + "start_timestamp": 1564344000, + "sub_title": "Z=115: One Second, One Grain", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003164/chapter_thumbnail/38205.webp?hash=siDTwSZELMa-9LFWGu7kVA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003686, + "end_timestamp": 2145884400, + "name": "#116", + "start_timestamp": 1564948800, + "sub_title": "Z=116: Miracle in Hand", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003686/chapter_thumbnail/38505.webp?hash=A6cThTDIitI1SCmOJ1zlIA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003687, + "end_timestamp": 2145884400, + "name": "#117", + "start_timestamp": 1566158400, + "sub_title": "Z=117: The Kingdom of Science Strikes Back", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003687/chapter_thumbnail/39078.webp?hash=0BlghZxTJKhwLWukMTgbpw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003688, + "end_timestamp": 2145884400, + "name": "#118", + "start_timestamp": 1566763200, + "sub_title": "Z=118: Silent Soldiers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003688/chapter_thumbnail/39447.webp?hash=BOmfpYlXy2R0WZY9NaPQqQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003689, + "end_timestamp": 2145884400, + "name": "#119", + "start_timestamp": 1567368000, + "sub_title": "Z=119: Science Soldiers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003689/chapter_thumbnail/39783.webp?hash=AS7xfKB6pSGwCFgWFAYl8w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003831, + "end_timestamp": 2145884400, + "name": "#120", + "start_timestamp": 1567972800, + "sub_title": "Z=120: Top Seacret", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003831/chapter_thumbnail/40152.webp?hash=PVktDWjyQk8mwbjIvEE-Qw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003832, + "end_timestamp": 2145884400, + "name": "#121", + "start_timestamp": 1568404800, + "sub_title": "Z=121: Medusa's True Face", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003832/chapter_thumbnail/40827.webp?hash=cPruke5o4u_-k1eRRpddlQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003833, + "end_timestamp": 2145884400, + "name": "#122", + "start_timestamp": 1569009600, + "sub_title": "Z=122: Brain-Battle Puzzle Pieces", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003833/chapter_thumbnail/43131.webp?hash=DGbtAyr22Q411z88sl5rYg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003834, + "end_timestamp": 2145884400, + "name": "#123", + "start_timestamp": 1569787200, + "sub_title": "Z=123: Brain-Battle Gambit", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003834/chapter_thumbnail/44607.webp?hash=-zjNfxoemuVFhw49911FSA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003835, + "end_timestamp": 2145884400, + "name": "#124", + "start_timestamp": 1570392000, + "sub_title": "Z=124: Invention of Gods and Devils", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003835/chapter_thumbnail/45504.webp?hash=WmHY2BoMe5uPyI4vFE-oPQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005646, + "end_timestamp": 2145884400, + "name": "#125", + "start_timestamp": 1570824000, + "sub_title": "Z=125: Decisive Three-Dimensional Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005646/chapter_thumbnail/46446.webp?hash=bDQeVuxhfu0T9q-nYdHI4w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005647, + "end_timestamp": 2145884400, + "name": "#126", + "start_timestamp": 1571601600, + "sub_title": "Z=126: Three-Dimensional Stratagem", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005647/chapter_thumbnail/47829.webp?hash=fr3lpZXbm2eu0b5XxPjLrA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005648, + "end_timestamp": 2145884400, + "name": "#127", + "start_timestamp": 1572206400, + "sub_title": "Z=127: Medusa & Perseus", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005648/chapter_thumbnail/49944.webp?hash=PjCvW8IHUM4gZyja21G5og&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005834, + "end_timestamp": 2145884400, + "name": "#128", + "start_timestamp": 1572638400, + "sub_title": "Z=128: Island-Wide Battle Royale", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005834/chapter_thumbnail/50871.webp?hash=rueKC0ask_CM_fUvLiZD9g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005925, + "end_timestamp": 2145884400, + "name": "#129", + "start_timestamp": 1573416000, + "sub_title": "Z=129: Joker", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005925/chapter_thumbnail/51642.webp?hash=dUZdorXYQVlwTcGbnWk5VA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005926, + "end_timestamp": 2145884400, + "name": "#130", + "start_timestamp": 1574020800, + "sub_title": "Z=130: Devil's Choice", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005926/chapter_thumbnail/53163.webp?hash=ewBT-lggD1ePGjKT9pHJ4w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005927, + "end_timestamp": 2145884400, + "name": "#131", + "start_timestamp": 1575230400, + "sub_title": "Z=131: Nasty Crimes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005927/chapter_thumbnail/54945.webp?hash=NbWgxvImfl6JPQA6qI3w2g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006041, + "end_timestamp": 2145884400, + "name": "#132", + "start_timestamp": 1575835200, + "sub_title": "Z=132: The Strongest Weapon Is...", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006041/chapter_thumbnail/56670.webp?hash=K18kmmaBjlmmP-Cc5iXzUw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006042, + "end_timestamp": 2145884400, + "name": "#133", + "start_timestamp": 1576440000, + "sub_title": "Z=133: Flash of Destruction", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006042/chapter_thumbnail/57015.webp?hash=QyEosL_gjg9Xjyi856FGdw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006043, + "end_timestamp": 2145884400, + "name": "#134", + "start_timestamp": 1577044800, + "sub_title": "Z=134: Commander Faceoff", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006043/chapter_thumbnail/57471.webp?hash=o83Z5kC_l7mJXpswrJUFkg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006044, + "end_timestamp": 2145884400, + "name": "#135", + "start_timestamp": 1579446000, + "sub_title": "Z=135: Counting", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006044/chapter_thumbnail/58992.webp?hash=yOHttqjc2MHf6PlawQ8XwA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006191, + "end_timestamp": 2145884400, + "name": "#136", + "start_timestamp": 1580050800, + "sub_title": "Z=136: Medusa vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006191/chapter_thumbnail/59622.webp?hash=6DhOq-TOcJhUCu_bWs0qbA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006277, + "end_timestamp": 2145884400, + "name": "#137", + "start_timestamp": 1580655600, + "sub_title": "Z=137: Last Man Standing", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006277/chapter_thumbnail/61398.webp?hash=DC895nUMwc6QetrCNmnzdQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006278, + "end_timestamp": 2145884400, + "name": "#138", + "start_timestamp": 1581264000, + "sub_title": "Z=138: End of Part 3", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006278/chapter_thumbnail/61797.webp?hash=Mz4gIirvswI89yhXtJM4uQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006279, + "end_timestamp": 2145884400, + "name": "#139", + "start_timestamp": 1581868800, + "sub_title": "Z=139: First Dream", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006279/chapter_thumbnail/62850.webp?hash=cTxE6gy2wT_IdBdFbtR1sA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006280, + "end_timestamp": 2145884400, + "name": "#140", + "start_timestamp": 1582300800, + "sub_title": "Z=140: New-World Pilots", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006280/chapter_thumbnail/63723.webp?hash=o1ImmhAtYwYw45P1bOSl6A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006412, + "end_timestamp": 2145884400, + "name": "#141", + "start_timestamp": 1583078400, + "sub_title": "Z=141: First Team", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006412/chapter_thumbnail/64818.webp?hash=WF6eLOvIhjc8jArqXfVSnQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006413, + "end_timestamp": 2145884400, + "name": "#142", + "start_timestamp": 1583683200, + "sub_title": "Z=142: World Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006413/chapter_thumbnail/65253.webp?hash=qrDgKUv8Q0l1HO0lALMLyg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006414, + "end_timestamp": 2145884400, + "name": "#143", + "start_timestamp": 1584288000, + "sub_title": "Z=143: Ryusui vs. Senku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006414/chapter_thumbnail/93558.webp?hash=2Qo5G2mRT0rgIoPEyot-2Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006415, + "end_timestamp": 2145884400, + "name": "#144", + "start_timestamp": 1584892800, + "sub_title": "Z=144: Ryusui & Gen vs. Senku & Kohaku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006415/chapter_thumbnail/94215.webp?hash=leZQGK0LHyh5XpVoKOXS2w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006416, + "end_timestamp": 2145884400, + "name": "#145", + "start_timestamp": 1585497600, + "sub_title": "Z=145: Bar Francois", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006416/chapter_thumbnail/94623.webp?hash=YqH1uEsCD2jkdahJ9loeIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006560, + "end_timestamp": 2145884400, + "name": "#146", + "start_timestamp": 1586102400, + "sub_title": "Z=146: Bar Francois: Bitters", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006560/chapter_thumbnail/95598.webp?hash=81thI0zq5U7DZCbqMLDtng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006561, + "end_timestamp": 2145884400, + "name": "#147", + "start_timestamp": 1586707200, + "sub_title": "Z=147: Science Journey", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006561/chapter_thumbnail/95982.webp?hash=CPN5XxopGOPr7ajxd6voXA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006562, + "end_timestamp": 2145884400, + "name": "#148", + "start_timestamp": 1587916800, + "sub_title": "Z=148: Pioneers of Earth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006562/chapter_thumbnail/96612.webp?hash=-Q-a7xRsBfO5wdGfPNWo3g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006563, + "end_timestamp": 2145884400, + "name": "#149", + "start_timestamp": 1589126400, + "sub_title": "Z=149: Light Lure in Darkness", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006563/chapter_thumbnail/98184.webp?hash=hNAWuVI_yG3CR6Lx-rFYcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006671, + "end_timestamp": 2145884400, + "name": "#150", + "start_timestamp": 1589731200, + "sub_title": "Z=150: Righteous Science-User", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006671/chapter_thumbnail/98889.webp?hash=-MBgU_z4VlrTb9_DXEnbSw&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "200", + "mid_chapter_list": [ + { + "chapter_id": 1006672, + "end_timestamp": 2145884400, + "name": "#151", + "start_timestamp": 1590336000, + "sub_title": "Z=151: Dr. X", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006672/chapter_thumbnail/99852.webp?hash=F0a2bGJqosDZi22OdReUDw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006958, + "end_timestamp": 2145884400, + "name": "#152", + "start_timestamp": 1590940800, + "sub_title": "Z=152: Doctor Vs. Doctor", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006958/chapter_thumbnail/100470.webp?hash=3k7VNHmBWXz2l2311FEGpg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007021, + "end_timestamp": 2145884400, + "name": "#153", + "start_timestamp": 1591545600, + "sub_title": "Z=153: War Game", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007021/chapter_thumbnail/132180.webp?hash=MPrCwvsagdhV-2s3qgY5BQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007022, + "end_timestamp": 2145884400, + "name": "#154", + "start_timestamp": 1592150400, + "sub_title": "Z=154: Spy vs. Spy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007022/chapter_thumbnail/132756.webp?hash=VCoApZMbM2P1vv3oSVEE2g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007023, + "end_timestamp": 2145884400, + "name": "#155", + "start_timestamp": 1592755200, + "sub_title": "Z=155: Science is Elegant", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007023/chapter_thumbnail/133545.webp?hash=KZF3ZYCbL85n5-Ub4rpFWA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007024, + "end_timestamp": 2145884400, + "name": "#156", + "start_timestamp": 1593187200, + "sub_title": "Z=156: Two Scientists", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007024/chapter_thumbnail/133941.webp?hash=3IpAqFpgqSp8Ks01Lth2Dw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007322, + "end_timestamp": 2145884400, + "name": "#157", + "start_timestamp": 1593964800, + "sub_title": "Z=157: Same Time, Same Place", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007322/chapter_thumbnail/134883.webp?hash=OfpX7zIt5JfOtoBgwRPcpg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007323, + "end_timestamp": 2145884400, + "name": "#158", + "start_timestamp": 1594569600, + "sub_title": "Z=158: Who's the Scientist?", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007323/chapter_thumbnail/135444.webp?hash=G9OClUAe3lanmsF0trzkoQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007324, + "end_timestamp": 2145884400, + "name": "#159", + "start_timestamp": 1595174400, + "sub_title": "Z=159: Lock On", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007324/chapter_thumbnail/135786.webp?hash=QlmJ_0ss76jD70pNVBITcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007499, + "end_timestamp": 2145884400, + "name": "#160", + "start_timestamp": 1596384000, + "sub_title": "Z=160: Torch of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007499/chapter_thumbnail/136368.webp?hash=5erX7iz_yQcu0Si6mOo97g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007500, + "end_timestamp": 2145884400, + "name": "#161", + "start_timestamp": 1597075200, + "sub_title": "Z=161: Craft Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007500/chapter_thumbnail/136821.webp?hash=1e5xxguqEvosdKfluj6SdA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007501, + "end_timestamp": 2145884400, + "name": "#162", + "start_timestamp": 1598198400, + "sub_title": "Z=162: Down the Earth-Stained Path", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007501/chapter_thumbnail/137424.webp?hash=DNaDiDkckyAlFV8aorLE2A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007502, + "end_timestamp": 2145884400, + "name": "#163", + "start_timestamp": 1598803200, + "sub_title": "Z=163: Multifront Final Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007502/chapter_thumbnail/137847.webp?hash=VmOKrBX2-_oraOgDyEzTMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007636, + "end_timestamp": 2145884400, + "name": "#164", + "start_timestamp": 1599408000, + "sub_title": "Z=164: Re-Lock On", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007636/chapter_thumbnail/138441.webp?hash=COhR5SDvImRhklQm1lUG9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007671, + "end_timestamp": 2145884400, + "name": "#165", + "start_timestamp": 1600012800, + "sub_title": "Z=165: Know the Rules, Make the Rules", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007671/chapter_thumbnail/138942.webp?hash=8ES6YF0FiYajyNYfEY4s8g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007672, + "end_timestamp": 2145884400, + "name": "#166", + "start_timestamp": 1600444800, + "sub_title": "Z=166: Ultimate Knight", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007672/chapter_thumbnail/139599.webp?hash=QJhqvRUk9ZJhAJmbwS60xA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007673, + "end_timestamp": 2145884400, + "name": "#167", + "start_timestamp": 1601222400, + "sub_title": "Z=167: Different Strokes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007673/chapter_thumbnail/139947.webp?hash=wi8LsA5khlwqjuspKuQZdg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007793, + "end_timestamp": 2145884400, + "name": "#168", + "start_timestamp": 1601827200, + "sub_title": "Z=168: Corn City: Population One Million", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007793/chapter_thumbnail/140328.webp?hash=WaOd-zjI39tLLomHDwStHQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007794, + "end_timestamp": 2145884400, + "name": "#169", + "start_timestamp": 1602432000, + "sub_title": "Z=169: RISK or HEART", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007794/chapter_thumbnail/141249.webp?hash=mczulOLelmzOBJk0SnKKMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007795, + "end_timestamp": 2145884400, + "name": "#170", + "start_timestamp": 1602864000, + "sub_title": "Z=170: Staring Up at the Same Moon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007795/chapter_thumbnail/141816.webp?hash=HxPrQYQ6T4wmH_40da0KXg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007796, + "end_timestamp": 2145884400, + "name": "#171", + "start_timestamp": 1603641600, + "sub_title": "Z=171: Staring at the Same Light", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007796/chapter_thumbnail/142398.webp?hash=hZ1cwOPygz18fc8JCM5PtA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007942, + "end_timestamp": 2145884400, + "name": "#172", + "start_timestamp": 1604246400, + "sub_title": "Z=172: Marked with an \"X\" of Wisdom", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007942/chapter_thumbnail/142857.webp?hash=N1yA6aU7yc3DcVBl3C9B2w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007943, + "end_timestamp": 2145884400, + "name": "#173", + "start_timestamp": 1604851200, + "sub_title": "Z=173: Earth Race", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007943/chapter_thumbnail/143526.webp?hash=WRDgI9iFHsOzwAuPz72YGw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007944, + "end_timestamp": 2145884400, + "name": "#174", + "start_timestamp": 1605456000, + "sub_title": "Z=174: The Specter of the Panama Canal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007944/chapter_thumbnail/143907.webp?hash=uQ7h0A7HJcd_oRxfMAlSIA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007945, + "end_timestamp": 2145884400, + "name": "#175", + "start_timestamp": 1605888000, + "sub_title": "Z=175: Ultra Race Across South America", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007945/chapter_thumbnail/144726.webp?hash=Y11V-Rh4u6n9JE9-GX4i5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007946, + "end_timestamp": 2145884400, + "name": "#176", + "start_timestamp": 1606665600, + "sub_title": "Z=176: Net-Breaking Battle Plan", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007946/chapter_thumbnail/145248.webp?hash=fznkTgH3QYlrCUcZwYSupA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008144, + "end_timestamp": 2145884400, + "name": "#177", + "start_timestamp": 1607270400, + "sub_title": "Z=177: Medusa Mechanism", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008144/chapter_thumbnail/145770.webp?hash=_NNpTBXicFviARtqwyU3eQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008145, + "end_timestamp": 2145884400, + "name": "#178", + "start_timestamp": 1607875200, + "sub_title": "Z=178: Science Scales Mountains", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008145/chapter_thumbnail/146346.webp?hash=5kh2zyEGa0kitKvGxCk9eQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008146, + "end_timestamp": 2145884400, + "name": "#179", + "start_timestamp": 1608480000, + "sub_title": "Z=179: Bonds on the High-Wire", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008146/chapter_thumbnail/146958.webp?hash=Zhg8eBi6x173AjdsSPho7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008147, + "end_timestamp": 2145884400, + "name": "#180", + "start_timestamp": 1609689600, + "sub_title": "Z=180: Sickening Yet Beautiful", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008147/chapter_thumbnail/148024.webp?hash=AND0zy_Wlm1SkIB_OScPLA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008373, + "end_timestamp": 2145884400, + "name": "#181", + "start_timestamp": 1610899200, + "sub_title": "Z=181: New World Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008373/chapter_thumbnail/148825.webp?hash=H9UuM-GsoNvGXetiUF3ICA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008374, + "end_timestamp": 2145884400, + "name": "#182", + "start_timestamp": 1611504000, + "sub_title": "Z=182: Diamond Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008374/chapter_thumbnail/149326.webp?hash=4tvsa1dx3PAVezbzqYBVfA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008375, + "end_timestamp": 2145884400, + "name": "#183", + "start_timestamp": 1612108800, + "sub_title": "Z=183: Stone Sanctuary", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008375/chapter_thumbnail/150367.webp?hash=jN4h0hNzv7gFSbTUuu-msQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008530, + "end_timestamp": 2145884400, + "name": "#184", + "start_timestamp": 1612713600, + "sub_title": "Z=184: Fort Medusa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008530/chapter_thumbnail/150865.webp?hash=gr31hLWgLJEmAaA59HZ7qw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008531, + "end_timestamp": 2145884400, + "name": "#185", + "start_timestamp": 1613318400, + "sub_title": "Z=185: Lovely Cleavage Plane", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008531/chapter_thumbnail/151399.webp?hash=fnR0M5JT8HTq3sGoWTKrqA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008532, + "end_timestamp": 2145884400, + "name": "#186", + "start_timestamp": 1613923200, + "sub_title": "Z=186: To Each Their Own Blade", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008532/chapter_thumbnail/151993.webp?hash=Yru_NHjRX4sggp0BnxOp_A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008937, + "end_timestamp": 2145884400, + "name": "#187", + "start_timestamp": 1614528000, + "sub_title": "Z=187: Cyber Guerilla", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008937/chapter_thumbnail/152443.webp?hash=rh1n10fQSPGnD6syyjQAxg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008938, + "end_timestamp": 2145884400, + "name": "#188", + "start_timestamp": 1615132800, + "sub_title": "Z=188: What I Once Sought to Destroy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008938/chapter_thumbnail/154504.webp?hash=dKnzMwSwCrBJovpITT7uzg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008939, + "end_timestamp": 2145884400, + "name": "#189", + "start_timestamp": 1615737600, + "sub_title": "Z=189: Our Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008939/chapter_thumbnail/156214.webp?hash=4nADYBXysQe3D7CTiu91ww&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008940, + "end_timestamp": 2145884400, + "name": "#190", + "start_timestamp": 1616342400, + "sub_title": "Z=190: Science Transcends Life", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008940/chapter_thumbnail/160108.webp?hash=VfPC7bYzyVjYZNKHhix2RQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008941, + "end_timestamp": 2145884400, + "name": "#191", + "start_timestamp": 1617552000, + "sub_title": "Z=191: Divine Scream, Down to Earth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008941/chapter_thumbnail/161434.webp?hash=y420Wi4cudUli9F1fln8ng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009095, + "end_timestamp": 2145884400, + "name": "#192", + "start_timestamp": 1618156800, + "sub_title": "Z=192: Until We Meet Again", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009095/chapter_thumbnail/163309.webp?hash=TNQMnFakkhn9SvmTzQlTpw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009096, + "end_timestamp": 2145884400, + "name": "#193", + "start_timestamp": 1618761600, + "sub_title": "Z=193: Our Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009096/chapter_thumbnail/164767.webp?hash=Ke8beacJFROVoC9NuWlw-g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009097, + "end_timestamp": 2145884400, + "name": "#194", + "start_timestamp": 1619366400, + "sub_title": "Z=194: Homo Sapiens, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009097/chapter_thumbnail/165969.webp?hash=0J0h_3oCLrOY0jErgSMe_A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009231, + "end_timestamp": 2145884400, + "name": "#195", + "start_timestamp": 1620576000, + "sub_title": "Z=195: Treasure Hunter, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009231/chapter_thumbnail/167259.webp?hash=_XysckPLY_XtUI81CuiQ1Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009232, + "end_timestamp": 2145884400, + "name": "#196", + "start_timestamp": 1621180800, + "sub_title": "Z=196: Scientist, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009232/chapter_thumbnail/168208.webp?hash=lryTizGi8PsrFDnGFDdpkw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009233, + "end_timestamp": 2145884400, + "name": "#197", + "start_timestamp": 1621785600, + "sub_title": "Z=197: A Stony Eden and Its Forbidden Fruit", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009233/chapter_thumbnail/169013.webp?hash=IyB0J-aHcfUTMIwckMS2ng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009396, + "end_timestamp": 2145884400, + "name": "#198", + "start_timestamp": 1622390400, + "sub_title": "Z=198: Whole New World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009396/chapter_thumbnail/170043.webp?hash=RVnV3gYO0j2FogT_0uR2tw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009397, + "end_timestamp": 2145884400, + "name": "#199", + "start_timestamp": 1622995200, + "sub_title": "Z=199: Superalloys", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009397/chapter_thumbnail/171418.webp?hash=PScSL18xjBf_Cz2UmTH1lg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009398, + "end_timestamp": 2145884400, + "name": "#200", + "start_timestamp": 1623600000, + "sub_title": "Z=200: Future Engine", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009398/chapter_thumbnail/172501.webp?hash=7SV9ixq491wzY9fEU4EDXQ&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "250", + "mid_chapter_list": [ + { + "chapter_id": 1009399, + "end_timestamp": 2145884400, + "name": "#201", + "start_timestamp": 1624204800, + "sub_title": "Z=201: Morse Talk", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009399/chapter_thumbnail/173074.webp?hash=zrycmlHjyDq9mR5_QGYlag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009400, + "end_timestamp": 2145884400, + "name": "#202", + "start_timestamp": 1624809600, + "sub_title": "Z=202: Ryusui Corp.", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009400/chapter_thumbnail/173518.webp?hash=Ztz9Z6VHuL4vcot1gep1kA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009560, + "end_timestamp": 2145884400, + "name": "#203", + "start_timestamp": 1625414400, + "sub_title": "Z=203: Missile Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009560/chapter_thumbnail/174097.webp?hash=bdQvq90fWJ34p2fU8VRUeQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009561, + "end_timestamp": 2145884400, + "name": "#204", + "start_timestamp": 1626019200, + "sub_title": "Z=204: The Universe is Written in the Language of Mathematics", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009561/chapter_thumbnail/174466.webp?hash=VWBzL9dfcn2_YrvZgzOBUw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009562, + "end_timestamp": 2145884400, + "name": "#205", + "start_timestamp": 1626624000, + "sub_title": "Z=205: Universe of Zeroes and Ones", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009562/chapter_thumbnail/174862.webp?hash=D5U0kLsAIMqNfxrsm-ZyJw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009667, + "end_timestamp": 2145884400, + "name": "#206", + "start_timestamp": 1628521200, + "sub_title": "Z=206: Dawn of the Computer", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009667/chapter_thumbnail/176716.webp?hash=usdbXDYoDfh9Yclpmb9qsA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009668, + "end_timestamp": 2145884400, + "name": "#207", + "start_timestamp": 1629644400, + "sub_title": "Z=207: Linking the Circuit Diagram", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009668/chapter_thumbnail/178006.webp?hash=CFtjUQ6GFHxCMt0r5kiHrg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009669, + "end_timestamp": 2145884400, + "name": "#208", + "start_timestamp": 1630249200, + "sub_title": "Z=208: Science Transcends Humanity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009669/chapter_thumbnail/178759.webp?hash=Q9kZIaBIQg9KqPlAB7Z2Ug&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009823, + "end_timestamp": 2145884400, + "name": "#209", + "start_timestamp": 1630854000, + "sub_title": "Z=209: The Rocket's Hard Truth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009823/chapter_thumbnail/180847.webp?hash=HHoRvrDO495Sz4_XpiCRMw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009824, + "end_timestamp": 2145884400, + "name": "#210", + "start_timestamp": 1631458800, + "sub_title": "Z=210: Not One-Way", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009824/chapter_thumbnail/181405.webp?hash=eXCeLW_Tv0pIgkIV2wgnQg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009825, + "end_timestamp": 2145884400, + "name": "#211", + "start_timestamp": 1631890800, + "sub_title": "Z=211: World Tour for Resources", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009825/chapter_thumbnail/182512.webp?hash=ZjZyOwvx4t2Tu4IDz9GW-w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009826, + "end_timestamp": 2145884400, + "name": "#212", + "start_timestamp": 1632668400, + "sub_title": "Z=212: Final Part: Stone to Space", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009826/chapter_thumbnail/183277.webp?hash=r_9epfICJxl0Q9wNzJUBbQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010013, + "end_timestamp": 2145884400, + "name": "#213", + "start_timestamp": 1633878000, + "sub_title": "Z=213: Unknown Known", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010013/chapter_thumbnail/185056.webp?hash=IyaQjVXFHvm8_YHrV27MCw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010014, + "end_timestamp": 2145884400, + "name": "#214", + "start_timestamp": 1634482800, + "sub_title": "Z=214: Stone World's Earth Defense Force", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010014/chapter_thumbnail/185821.webp?hash=lxOkrCvKBlEYKSL0BWfGVg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010015, + "end_timestamp": 2145884400, + "name": "#215", + "start_timestamp": 1635087600, + "sub_title": "Z=215: Long, Long Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010015/chapter_thumbnail/186373.webp?hash=1PCW4vjbCFDR96xT-sQgtw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010484, + "end_timestamp": 2145884400, + "name": "#216", + "start_timestamp": 1635692400, + "sub_title": "Z=216: Hello, World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010484/chapter_thumbnail/186871.webp?hash=kGUYhMJAyH20S5KK7s4PpQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010485, + "end_timestamp": 2145884400, + "name": "#217", + "start_timestamp": 1636297200, + "sub_title": "Z=217: Science Underdogs", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010485/chapter_thumbnail/187417.webp?hash=bjgqkbb3DLjL2kMEveYDBw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010486, + "end_timestamp": 2145884400, + "name": "#218", + "start_timestamp": 1636902000, + "sub_title": "Z=218: WWW", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010486/chapter_thumbnail/188866.webp?hash=aLoJEJ5yclqOf2Ol5d2QWg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010487, + "end_timestamp": 2145884400, + "name": "#219", + "start_timestamp": 1637506800, + "sub_title": "Z=219: Three Heroes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010487/chapter_thumbnail/195337.webp?hash=d3YGY3H-tICTT7KCsuwdDQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010488, + "end_timestamp": 2145884400, + "name": "#220", + "start_timestamp": 1638111600, + "sub_title": "Z=220: A Desire for All", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010488/chapter_thumbnail/195907.webp?hash=ZZk0k8pmX_BK-zXbLH2fQQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011912, + "end_timestamp": 2145884400, + "name": "#221", + "start_timestamp": 1638716400, + "sub_title": "Z=221: Entrusting It All", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011912/chapter_thumbnail/196519.webp?hash=2M-5i9toJ5K3N219TQ19iw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011913, + "end_timestamp": 2145884400, + "name": "#222", + "start_timestamp": 1639321200, + "sub_title": "Z=222: Science Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011913/chapter_thumbnail/197146.webp?hash=vXarR67elYtpV3c6ikdPMw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011914, + "end_timestamp": 2145884400, + "name": "#223", + "start_timestamp": 1639926000, + "sub_title": "Z=223: 0", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011914/chapter_thumbnail/203110.webp?hash=sXhNPVVsTOJYGUAOWHQW0g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011915, + "end_timestamp": 2145884400, + "name": "#224", + "start_timestamp": 1641222000, + "sub_title": "Z=224: In Space", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011915/chapter_thumbnail/203710.webp?hash=AZp9GkSVNPJ1O0lM4RAYtQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012639, + "end_timestamp": 2145884400, + "name": "#225", + "start_timestamp": 1642345200, + "sub_title": "Z=225: Docking", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012639/chapter_thumbnail/206689.webp?hash=odLViJKTJpZSb89E0PsMhQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012640, + "end_timestamp": 2145884400, + "name": "#226", + "start_timestamp": 1642950000, + "sub_title": "Z=226: Giant Step", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012640/chapter_thumbnail/208087.webp?hash=MBx2zrkQl0gQFEJB8eUWNw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012641, + "end_timestamp": 2145884400, + "name": "#227", + "start_timestamp": 1643554800, + "sub_title": "Z=227: It Was You", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012641/chapter_thumbnail/208654.webp?hash=lybOixSqPsF8VAsz1Xea-A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012739, + "end_timestamp": 2145884400, + "name": "#228", + "start_timestamp": 1644159600, + "sub_title": "Z=228: Life Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012739/chapter_thumbnail/209389.webp?hash=KfmHc9aHhzpnwAkaOHQ8KQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012780, + "end_timestamp": 2145884400, + "name": "#229", + "start_timestamp": 1644764400, + "sub_title": "Z=229: Why-Man", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012780/chapter_thumbnail/209951.webp?hash=dFAFL6S6RwV0CCbPmApu_w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012800, + "end_timestamp": 2145884400, + "name": "#230", + "start_timestamp": 1645369200, + "sub_title": "Z=230: Human", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012800/chapter_thumbnail/210667.webp?hash=og1aO4iRrESI8enBmLz85g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012836, + "end_timestamp": 2145884400, + "name": "#231", + "start_timestamp": 1645974000, + "sub_title": "Z=231: A Future to Get Excited About", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012836/chapter_thumbnail/211153.webp?hash=H2HA_Cs5sv_flSoIRwkxQw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012941, + "end_timestamp": 2145884400, + "name": "#232", + "start_timestamp": 1646578800, + "sub_title": "Final Chapter: Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012941/chapter_thumbnail/212383.webp?hash=t2aDIU6Sv_L2VKonxovrUQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1013713, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1656860400, + "sub_title": "Terraforming", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1013713/chapter_thumbnail/224772.webp?hash=inyeMQx-plU0Pg3R_rxQLA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019503, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1699196400, + "sub_title": "Chapter 1D: Future Message", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019503/chapter_thumbnail/320824.webp?hash=NypZUqHyfnFG3kEAH5afTw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019504, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1702825200, + "sub_title": "Chapter 2D: Future Road Map", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019504/chapter_thumbnail/327171.webp?hash=tGCzSPrCzxsblrkhezsl-g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019502, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1703430000, + "sub_title": "Chapter 3D: Future Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019502/chapter_thumbnail/328206.webp?hash=xemI-0fCALxRwdhGQhOKMg&expires=1771027200", + "title_id": 100010 + } + ] + } + ], + "is_simul_released": true, + "number_of_views": 17498075, + "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he's not alone! His science-loving friend Senku's been up and running for a few months and he's got a grand plan in mind?to kickstart civilization with the power of science!", + "rating": 1, + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010" + }, + "title": { + "author": "Riichiro Inagaki / Boichi", + "name": "Dr. STONE", + "portrait_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/title_thumbnail_portrait_list/312364.webp?hash=N8-maxyynA0pzXUBWRCIWQ&expires=2145884400", + "title_id": 100010 + }, + "title_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/title_thumbnail_main/312361.webp?hash=ExKiQG_xvo2fULojK5vRzg&expires=2145884400", + "viewing_period_description": "The latest 0 chapters are viewable in this title.\nPlease be aware that the 0th latest chapter will be hidden when a new chapter is added." + } + } +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json new file mode 100644 index 0000000..da02409 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json @@ -0,0 +1,19 @@ +{ + "captured_at_utc": "2026-02-13T22:09:00.985947+00:00", + "endpoint": "manga_viewer", + "identifier": "1000311", + "params": { + "app_ver": "97", + "chapter_id": 1000311, + "img_quality": "super_high", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "no" + }, + "parsed_payload_file": "0002_manga_viewer_1000311.response.json", + "payload_sha256": "dde00e41e9180640002f62de69aeb660872aa774bd3c4c69cf7b2d76b34df85f", + "payload_size_bytes": 6718, + "raw_payload_file": "0002_manga_viewer_1000311.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb new file mode 100644 index 0000000..c81b12a Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb differ diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json new file mode 100644 index 0000000..37417fd --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json @@ -0,0 +1,297 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1000311, + "chapter_name": "#002", + "chapters": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "number_of_comments": 49, + "pages": [ + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/1.webp?hash=bKjjgQHsFFEuBwx6Ow1PTQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/2.webp?hash=kaLsldUtJ0ZOhXprD9abyw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/3.webp?hash=82xfHVaBvy_yKXs0G6pVwA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/4.webp?hash=ywmXzpqCkZoD8-fWx2HKtQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/5.webp?hash=YXkv1pxwX-_gE2ugpIk7xQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/6.webp?hash=ydAccfRn-bn0nHgl4xIiUQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/7.webp?hash=uu4kublp-2yKO-RJ6kE9Gw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/8.webp?hash=Iv971siQxU9N-LuM5z9vcA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/9.webp?hash=hPDydnvM_p9s6IGIigP_mQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/10.webp?hash=X9p_TfcXvzXoPm3OumJP8Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/11.webp?hash=sFIKF8fZED9dWC07ghzhtA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/12.webp?hash=6y_UH-RBevOS2W21sE7yVg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/13.webp?hash=MU-7ZukgYCcUVn3AQFvU6w&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/14.webp?hash=nT8MlorxX9pHFJGwAVFWDg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/15.webp?hash=UqPZe9U_2gqyxPqCzia-wg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/16.webp?hash=iK2uCv4FR-9cL7UgQBlrbA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/17.webp?hash=hc7EcLhbrR-WERKCVh81uA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/18.webp?hash=7W3PYonZFWYWzrYg5tTqUQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/19.webp?hash=Z4Yt-Ira50nmDIknf3HlXg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/20.webp?hash=3yh7li_QlHZfm6fbfIIu8Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/21.webp?hash=_zaaAYPhYgH2lr4WNVphoA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/22.webp?hash=Ks4TJqYURUi1zXpCOXFqLA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/23.webp?hash=J7RjON9SaV-Tf0gfs96K5Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/24.webp?hash=pn0vYwGM-KF0u_tIq50R2g&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/25.webp?hash=AZQ9apcQ1Geps76bVGG5SQ&expires=1771027200", + "width": 1400 + } + }, + { + "last_page": { + "advertisement": {}, + "chapter_type": 1, + "current_chapter": { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + "next_chapter": { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + }, + "top_comments": [ + { + "body": "This manga is awesome! I don't know if it's just me, but the main character reminds me of Edward from Fullmetal Alchemist. ", + "created": 1549306427, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/505.webp?hash=j62Z0uOg0cgxkucJtek5MA&expires=2145884400", + "id": 18618, + "index": 3, + "number_of_likes": 120, + "user_name": "🇺🇸ALL MIGHT🇺🇸" + }, + { + "body": "Honestly, I've only read a few chapters and Im immediately hooked. hope to see more! ", + "created": 1550665491, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/490.webp?hash=XxJv2yajXS_M78VVxF1xzg&expires=2145884400", + "id": 28317, + "index": 5, + "number_of_likes": 77, + "user_name": "TripleSG" + }, + { + "body": "This is the best manga/anime adaptation I’ve seen so far. I’m eager to take a look at how the story develops. Senku is mastermind and a very original character in the anime world. Was not expecting such a good manga when I read the sinopsis but 10/10 would recommend ", + "created": 1567466739, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/412.webp?hash=axvg14npK7yy5V1-JmgVXQ&expires=2145884400", + "id": 163847, + "index": 6, + "number_of_likes": 71, + "user_name": "ValeSenpaiuwu " + }, + { + "body": "bruh the manga is better than the anime change my mind", + "created": 1607121616, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/505.webp?hash=j62Z0uOg0cgxkucJtek5MA&expires=2145884400", + "id": 898232, + "index": 20, + "number_of_likes": 39, + "user_name": "I AM HERE!!!38:)" + }, + { + "body": "I love how he’s just like: “sour grapes? Can I eat them?”\nWithout giving a second thought as to how they might be poisonous 😂", + "created": 1603440374, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/38010.webp?hash=KylTxVmoCIBHe_2ee4RSMg&expires=2145884400", + "id": 803798, + "index": 19, + "number_of_likes": 34, + "user_name": "MeAndMyself" + } + ] + } + } + ], + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010&chapter_id=1000311" + }, + "title_id": 100010, + "title_name": "Dr. STONE" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json new file mode 100644 index 0000000..5a566a6 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json @@ -0,0 +1,19 @@ +{ + "captured_at_utc": "2026-02-13T22:09:13.355930+00:00", + "endpoint": "manga_viewer", + "identifier": "1000312", + "params": { + "app_ver": "97", + "chapter_id": 1000312, + "img_quality": "super_high", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "no" + }, + "parsed_payload_file": "0003_manga_viewer_1000312.response.json", + "payload_sha256": "bbd2d47306ad0006bb5a1311cb11cc80f94352b4b974999f315d3f5993c89faa", + "payload_size_bytes": 5989, + "raw_payload_file": "0003_manga_viewer_1000312.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb new file mode 100644 index 0000000..f783ded Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb differ diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json new file mode 100644 index 0000000..32bf2d6 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json @@ -0,0 +1,283 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1000312, + "chapter_name": "#003", + "chapters": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "number_of_comments": 86, + "pages": [ + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/1.webp?hash=PQvy_7kBIVt1KN0YaDnQkw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/2.webp?hash=OUz4TZnQ4rCDlLDU_xwD8g&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/3.webp?hash=VStx5uQwhk2LSYm5S845jA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/4.webp?hash=UOb4RmsGOUc9n1R7IfSNBw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/5.webp?hash=5fWogMq9zSMJtFPJjQp2Ug&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/6.webp?hash=8CcyBzLCeIbZKjrVCbOHNg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/7.webp?hash=yypQLF2IsvnXP7NuCZbNVw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/8.webp?hash=hp8E-gQCyL8Hx4EaH8CN_w&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/9.webp?hash=Ok6oc9u1Bza1s9h82eextA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/10.webp?hash=LLBTc8UkySapqrhKavwyFQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/11.webp?hash=H1R9bYwa1XS7jkqfivksRw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/12.webp?hash=jLgHd5xFdNPQYrvyyaToyQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/13.webp?hash=wy4FH5ezzR2-l1T_XveDow&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/14.webp?hash=dbCQwLMsnsWPxAWpYTIInA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/15.webp?hash=AaFd4C-poQI928RvF8HgBQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/16.webp?hash=7W5tTMJxFpHxJoh4xSqdgw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/17.webp?hash=LTTQZsXClaIXs_Bjy4JPaA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/18.webp?hash=c8SFXo-7622ibiPeKlFXuA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/19.webp?hash=eXFmkD4aeE3xG_PsPCWVlQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/20.webp?hash=EPqdgeXD6agtbBxVh_vBxg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/21.webp?hash=FSihJaZtqn_gogkIxTnFHg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/22.webp?hash=KYjKmhr4pdB43bc6QcM5AA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/23.webp?hash=DFM78wW6XOnd25j1PcsiRw&expires=1771027200", + "width": 1400 + } + }, + { + "last_page": { + "advertisement": {}, + "chapter_type": 2, + "current_chapter": { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + }, + "next_chapter": { + "already_viewed": true, + "chapter_id": 1000313, + "end_timestamp": 2145884400, + "name": "#004", + "start_timestamp": 1547996400, + "sub_title": "Z=4: Pure White Seashells", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000313/chapter_thumbnail/1693.webp?hash=l0MeH1w8JRGi_3c-58R5EQ&expires=1771027200", + "title_id": 100010 + }, + "top_comments": [ + { + "body": "Would love chapter 4 onward ", + "created": 1549332998, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/415.webp?hash=yB1cMi-rGDOohtAf-B7bgw&expires=2145884400", + "id": 18927, + "index": 3, + "number_of_likes": 200, + "user_name": "GlassHP" + }, + { + "body": "Yoo pls we need the missing chapters we cant go from chap 3 to 98 like that ! ", + "created": 1554851818, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/418.webp?hash=qFB5esuY30sGI7jXkW57tA&expires=2145884400", + "id": 70011, + "index": 11, + "number_of_likes": 173, + "user_name": "ohlekip" + }, + { + "body": "where are the other chapters before 89?", + "created": 1548762327, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/739.webp?hash=U240p1Hk9ZmZzSFwHFh0xg&expires=2145884400", + "id": 10704, + "index": 1, + "number_of_likes": 85, + "user_name": "shrek" + }, + { + "body": "I love Dr stone dude this manga is very fun ", + "created": 1549363307, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/304.webp?hash=_VtB92NbqWqxYMrK2SQWyg&expires=2145884400", + "id": 19236, + "index": 4, + "number_of_likes": 77, + "user_name": "Genta" + }, + { + "body": "the next chapters are still not available because they probably will upload a new one weekly, like the other series.", + "created": 1548817965, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/478.webp?hash=OyzGvQBuriqZu8JcCe5nHQ&expires=2145884400", + "id": 11763, + "index": 2, + "number_of_likes": 55, + "user_name": "Kurochou" + } + ] + } + } + ], + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010&chapter_id=1000312" + }, + "title_id": 100010, + "title_name": "Dr. STONE" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0004_title_index_all.meta.json b/tests/fixtures/api_captures/baseline/0004_title_index_all.meta.json new file mode 100644 index 0000000..8939a7e --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0004_title_index_all.meta.json @@ -0,0 +1,18 @@ +{ + "captured_at_utc": "2026-05-27T00:00:00+00:00", + "endpoint": "title_index", + "identifier": "all", + "params": { + "app_ver": "97", + "id_length": 6, + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***" + }, + "parsed_payload_file": "0004_title_index_all.response.json", + "payload_classification": "success", + "payload_sha256": "fb729f771372dc91ef8813ff2c3097aa2d66c83866b69b9582267517c85e8a48", + "payload_size_bytes": 395, + "raw_payload_file": "0004_title_index_all.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/title_list/allV2" +} diff --git a/tests/fixtures/api_captures/baseline/0004_title_index_all.pb b/tests/fixtures/api_captures/baseline/0004_title_index_all.pb new file mode 100644 index 0000000..5d07038 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0004_title_index_all.pb @@ -0,0 +1,4 @@ + + + +simulpub"Aliens, Baseball, and CivilizationFixture Author"Dhttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/portrait.webp*Ehttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/landscape.webp Dr. STONEFixture Author"Dhttps://jumpg-assets.tokyo-cdn.com/secure/title/100010/portrait.webp*Ehttps://jumpg-assets.tokyo-cdn.com/secure/title/100010/landscape.webp \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0004_title_index_all.response.json b/tests/fixtures/api_captures/baseline/0004_title_index_all.response.json new file mode 100644 index 0000000..335db48 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0004_title_index_all.response.json @@ -0,0 +1,27 @@ +{ + "success": { + "all_titles_view": { + "title_groups": [ + { + "group_name": "simulpub", + "titles": [ + { + "author": "Fixture Author", + "landscape_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/landscape.webp", + "name": "Aliens, Baseball, and Civilization", + "portrait_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/portrait.webp", + "title_id": 100494 + }, + { + "author": "Fixture Author", + "landscape_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/landscape.webp", + "name": "Dr. STONE", + "portrait_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/portrait.webp", + "title_id": 100010 + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.meta.json b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.meta.json new file mode 100644 index 0000000..12a582c --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.meta.json @@ -0,0 +1,18 @@ +{ + "captured_at_utc": "2026-05-27T00:00:00+00:00", + "endpoint": "title_detailV3", + "identifier": "100494", + "params": { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "title_id": 100494 + }, + "parsed_payload_file": "0005_title_detailV3_flat_chapter_list.response.json", + "payload_classification": "success", + "payload_sha256": "584e8cdcfbf36227f7a71c14363c333668e3c7da61f8dcf88f27a35391f2317b", + "payload_size_bytes": 564, + "raw_payload_file": "0005_title_detailV3_flat_chapter_list.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/title_detailV3" +} diff --git a/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.pb b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.pb new file mode 100644 index 0000000..2cae823 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.pb @@ -0,0 +1,3 @@ + +B +"Aliens, Baseball, and CivilizationTeito Heji / Sai Yamagishi"`https://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_portrait_list/423233.webp*Whttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_main/423230.webpWhttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_main/423230.webp!Mobile flat chapter-list fixture.>#001"*1st Pitch: Please Watch Out For Wormholes.*dhttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp08 \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.response.json b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.response.json new file mode 100644 index 0000000..2a66f5d --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0005_title_detailV3_flat_chapter_list.response.json @@ -0,0 +1,26 @@ +{ + "success": { + "title_detail_view": { + "chapter_list": [ + { + "chapter_id": 1024974, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1747407600, + "sub_title": "1st Pitch: Please Watch Out For Wormholes.", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp", + "title_id": 100494 + } + ], + "overview": "Mobile flat chapter-list fixture.", + "title": { + "author": "Teito Heji / Sai Yamagishi", + "landscape_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_main/423230.webp", + "name": "Aliens, Baseball, and Civilization", + "portrait_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_portrait_list/423233.webp", + "title_id": 100494 + }, + "title_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/title_thumbnail_main/423230.webp" + } + } +} diff --git a/tests/fixtures/api_captures/baseline/0006_title_index_api_error.meta.json b/tests/fixtures/api_captures/baseline/0006_title_index_api_error.meta.json new file mode 100644 index 0000000..844fb60 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0006_title_index_api_error.meta.json @@ -0,0 +1,23 @@ +{ + "api_error": { + "body": "There are issues connecting to Manga+. Please try again later.(10511)", + "code": "10511", + "language": 0, + "title": "Invalid Parameter" + }, + "captured_at_utc": "2026-05-27T00:00:00+00:00", + "endpoint": "title_index", + "identifier": "all", + "params": { + "app_ver": "97", + "id_length": 6, + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***" + }, + "payload_classification": "api_error", + "payload_sha256": "15cf8f1b1aa9b1c735b0f4b04881e91e060e90b05d6359392d771a5e0e717755", + "payload_size_bytes": 96, + "raw_payload_file": "0006_title_index_api_error.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/title_list/allV2" +} diff --git a/tests/fixtures/api_captures/baseline/0006_title_index_api_error.pb b/tests/fixtures/api_captures/baseline/0006_title_index_api_error.pb new file mode 100644 index 0000000..ce2e406 Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0006_title_index_api_error.pb differ diff --git a/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.meta.json b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.meta.json new file mode 100644 index 0000000..4e4ffab --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.meta.json @@ -0,0 +1,20 @@ +{ + "captured_at_utc": "2026-05-27T00:00:00+00:00", + "endpoint": "manga_viewer", + "expected_runtime_error": "subscription_required", + "identifier": "1029001", + "params": { + "app_ver": "97", + "chapter_id": 1029001, + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "yes" + }, + "parsed_payload_file": "0007_manga_viewer_subscription_required.response.json", + "payload_classification": "success", + "payload_sha256": "188b3b450747be7d36bf277478ba26364a42be8bf26beb4002ecd73ef10845cc", + "payload_size_bytes": 68, + "raw_payload_file": "0007_manga_viewer_subscription_required.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} diff --git a/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.pb b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.pb new file mode 100644 index 0000000..cf96214 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.pb @@ -0,0 +1,2 @@ + +BR@>*"Aliens, Baseball, and Civilization2MAX Locked ChapterH \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.response.json b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.response.json new file mode 100644 index 0000000..0d061b4 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0007_manga_viewer_subscription_required.response.json @@ -0,0 +1,10 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1029001, + "chapter_name": "MAX Locked Chapter", + "title_id": 100494, + "title_name": "Aliens, Baseball, and Civilization" + } + } +} diff --git a/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.meta.json b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.meta.json new file mode 100644 index 0000000..c8d4f0e --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.meta.json @@ -0,0 +1,19 @@ +{ + "captured_at_utc": "2026-05-27T00:00:00+00:00", + "endpoint": "manga_viewer", + "identifier": "1024974", + "params": { + "app_ver": "97", + "chapter_id": 1024974, + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "yes" + }, + "parsed_payload_file": "0008_manga_viewer_encrypted_page.response.json", + "payload_classification": "success", + "payload_sha256": "7a02eae2c8126534152c73fd38a21077de90bdbb677894b29463ee5af7c3ad0c", + "payload_size_bytes": 567, + "raw_payload_file": "0008_manga_viewer_encrypted_page.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} diff --git a/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.pb b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.pb new file mode 100644 index 0000000..07cbb0c --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.pb @@ -0,0 +1,8 @@ + +R + + +chttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/manga_page/super_high/1.webp + * 00112233445566778899aabbccddeeff + +>#001"*1st Pitch: Please Watch Out For Wormholes.*dhttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp080>>#001"*1st Pitch: Please Watch Out For Wormholes.*dhttps://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp08*"Aliens, Baseball, and Civilization2#0018*HP \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.response.json b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.response.json new file mode 100644 index 0000000..f1fbcfd --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0008_manga_viewer_encrypted_page.response.json @@ -0,0 +1,48 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1024974, + "chapter_name": "#001", + "chapters": [ + { + "chapter_id": 1024974, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1747407600, + "sub_title": "1st Pitch: Please Watch Out For Wormholes.", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp", + "title_id": 100494 + } + ], + "number_of_comments": 42, + "pages": [ + { + "manga_page": { + "encryption_key": "00112233445566778899aabbccddeeff", + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/manga_page/super_high/1.webp", + "type": 1, + "width": 1400 + } + }, + { + "last_page": { + "chapter_type": 1, + "current_chapter": { + "chapter_id": 1024974, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1747407600, + "sub_title": "1st Pitch: Please Watch Out For Wormholes.", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100494/chapter/1024974/chapter_thumbnail/423776.webp", + "title_id": 100494 + } + } + } + ], + "start_from_right": true, + "title_id": 100494, + "title_name": "Aliens, Baseball, and Civilization" + } + } +} diff --git a/tests/http_fakes.py b/tests/http_fakes.py new file mode 100644 index 0000000..76b8fd6 --- /dev/null +++ b/tests/http_fakes.py @@ -0,0 +1,98 @@ +"""Shared typed HTTP fakes for infrastructure tests.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Self + +import requests + + +class BytesResponse: + """Minimal bytes response double with requests-like status handling.""" + + def __init__(self, content: bytes | None = None, *, status_code: int = 200) -> None: + """Store binary response content and status code.""" + self.content = content if content is not None else b"" + self.status_code = status_code + self.status_checked = False + + def raise_for_status(self) -> None: + """Raise HTTPError for failed status codes.""" + self.status_checked = True + if self.status_code >= 400: + response = requests.Response() + response.status_code = self.status_code + raise requests.HTTPError(f"{self.status_code} error", response=response) + + +class TextResponse: + """Minimal text response double with requests-like status handling.""" + + def __init__(self, text: str = "", *, status_code: int = 200) -> None: + """Store text response content and status code.""" + self.text = text + self.status_code = status_code + + def raise_for_status(self) -> None: + """Raise HTTPError for failed status codes.""" + if self.status_code >= 400: + response = requests.Response() + response.status_code = self.status_code + raise requests.HTTPError(f"{self.status_code} error", response=response) + + +class BytesMappingSession: + """Context-manager session serving binary payloads by URL.""" + + def __init__(self, payloads: Mapping[str, bytes | BytesResponse]) -> None: + """Store URL-to-response mapping and initialize call tracking.""" + self.payloads = payloads + self.calls: list[tuple[str, Mapping[str, object] | None, tuple[float, float]]] = [] + self.headers: dict[str, str] = {} + + def __enter__(self) -> Self: + """Support requests.Session context manager usage.""" + return self + + def __exit__(self, *args: object) -> None: + """Support requests.Session context manager usage.""" + _ = args + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] = (5.0, 30.0), + ) -> BytesResponse: + """Record request details and return the mapped response.""" + self.calls.append((url, params, timeout)) + payload = self.payloads[url] + if isinstance(payload, BytesResponse): + return payload + return BytesResponse(content=payload) + + +class TextMappingSession: + """Context-manager session serving text payloads by URL.""" + + def __init__(self, payloads: Mapping[str, str | TextResponse]) -> None: + """Store URL-to-response mapping and initialize call tracking.""" + self.payloads = payloads + self.calls: list[tuple[str, tuple[float, float]]] = [] + + def __enter__(self) -> Self: + """Support requests.Session context manager usage.""" + return self + + def __exit__(self, *args: object) -> None: + """Support requests.Session context manager usage.""" + _ = args + + def get(self, url: str, timeout: tuple[float, float] = (5.0, 30.0)) -> TextResponse: + """Record request details and return the mapped response.""" + self.calls.append((url, timeout)) + payload = self.payloads[url] + if isinstance(payload, TextResponse): + return payload + return TextResponse(text=payload) diff --git a/tests/test_api_response.py b/tests/test_api_response.py new file mode 100644 index 0000000..3f16ff1 --- /dev/null +++ b/tests/test_api_response.py @@ -0,0 +1,194 @@ +"""Tests for raw MangaPlus API response classification.""" + +from __future__ import annotations + +import pytest + +from mloader.infrastructure.mangaplus import api_response +from mloader.infrastructure.mangaplus.api_response import ( + classify_api_response_payload, + format_api_payload_problem, +) +from mloader.response_pb2 import Response + + +def _varint(value: int) -> bytes: + """Encode a protobuf varint for fixture construction.""" + parts: list[int] = [] + while True: + byte = value & 0x7F + value >>= 7 + if value: + parts.append(byte | 0x80) + continue + parts.append(byte) + return bytes(parts) + + +def _length_delimited_field(field_number: int, value: bytes) -> bytes: + """Encode one length-delimited protobuf field.""" + key = (field_number << 3) | 2 + return _varint(key) + _varint(len(value)) + value + + +def _varint_field(field_number: int, value: int) -> bytes: + """Encode one varint protobuf field.""" + key = field_number << 3 + return _varint(key) + _varint(value) + + +def _fixed64_field(field_number: int) -> bytes: + """Encode one fixed64 protobuf field.""" + return _varint((field_number << 3) | 1) + b"12345678" + + +def _fixed32_field(field_number: int) -> bytes: + """Encode one fixed32 protobuf field.""" + return _varint((field_number << 3) | 5) + b"1234" + + +def _string_field(field_number: int, value: str) -> bytes: + """Encode one protobuf string field.""" + return _length_delimited_field(field_number, value.encode("utf-8")) + + +def _api_error_payload(*, message: str, code_language: int = 0) -> bytes: + """Build a minimal MangaPlus application-error envelope.""" + localized_error = ( + _string_field(1, "Invalid Parameter") + + _string_field(2, message) + + _varint_field(6, code_language) + ) + error_result = _length_delimited_field(2, localized_error) + return _length_delimited_field(2, error_result) + + +def test_classify_success_payload() -> None: + """Verify normal success envelopes classify as success.""" + response = Response() + group = response.success.all_titles_view.title_groups.add() + title = group.titles.add() + title.title_id = 100001 + title.name = "Demo" + + classification = classify_api_response_payload(response.SerializeToString()) + + assert classification.kind == "success" + assert classification.error is None + + +def test_classify_empty_success_payload_as_unknown() -> None: + """Verify empty success envelopes are treated as unknown schema payloads.""" + response = Response() + response.success.SetInParent() + + classification = classify_api_response_payload(response.SerializeToString()) + + assert classification.kind == "unknown" + assert classification.description == "unknown" + + +def test_classify_application_error_envelope() -> None: + """Verify MangaPlus application-error envelopes expose code and message.""" + payload = _api_error_payload( + message="There are issues connecting to Manga+. Please try again later.(10511)" + ) + + classification = classify_api_response_payload(payload) + + assert classification.kind == "api_error" + assert classification.error is not None + assert classification.error.title == "Invalid Parameter" + assert classification.error.code == "10511" + assert "Manga+" in classification.error.body + assert "api_error code=10511" in classification.description + + +def test_format_api_error_payload_problem() -> None: + """Verify API error diagnostics include title, body, and optional code.""" + payload = _api_error_payload(message="Plain upstream error") + classification = classify_api_response_payload(payload) + + assert classification.kind == "api_error" + assert "Invalid Parameter: Plain upstream error" in format_api_payload_problem( + classification, + context="title_index", + ) + assert classification.error is not None + assert classification.error.code is None + + +def test_format_api_payload_problem_mentions_schema_drift_for_unknown() -> None: + """Verify unknown payloads produce a schema-drift diagnostic.""" + classification = classify_api_response_payload(b"not-protobuf") + + assert classification.kind == "unknown" + assert "schema drift" in format_api_payload_problem(classification, context="title_index") + + +def test_empty_payload_classification() -> None: + """Verify empty response bodies classify explicitly.""" + classification = classify_api_response_payload(b"") + + assert classification.kind == "empty" + assert "empty payload" in format_api_payload_problem(classification, context="manga_viewer") + + +def test_extract_api_error_handles_non_matching_and_empty_error_branches() -> None: + """Verify non-error and empty error branches do not produce application errors.""" + assert api_response.extract_api_error(_length_delimited_field(1, b"ignored")) is None + assert api_response.extract_api_error(_length_delimited_field(2, b"")) is None + + +def test_extract_api_error_falls_back_to_first_localized_message() -> None: + """Verify non-English-only envelopes still return a useful first message.""" + localized_error = _string_field(1, "Invalid Parameter") + _string_field( + 2, + "Erreur amont sans anglais", + ) + payload = _length_delimited_field(2, _length_delimited_field(5, localized_error)) + + extracted = api_response.extract_api_error(payload) + + assert extracted is not None + assert extracted.body == "Erreur amont sans anglais" + + +def test_private_error_helpers_handle_edge_values() -> None: + """Cover defensive helper paths used by malformed upstream payloads.""" + + class BadHasField: + success: object = object() + + def HasField(self, field_name: str) -> bool: + del field_name + raise ValueError("bad field") + + assert api_response._has_populated_success(BadHasField()) is False + assert api_response._has_populated_success(Response()) is False + assert api_response._extract_error_messages(_varint_field(1, 1)) == [] + assert api_response._parse_error_message(_length_delimited_field(5, b"Close")) is None + assert api_response._looks_english("") is False + assert api_response._decode_text(b"\xff") == "" + + +def test_raw_field_iterator_handles_malformed_wire_shapes() -> None: + """Cover defensive wire parser branches for truncated/unsupported protobuf data.""" + assert api_response._iter_fields(b"", depth=99) == [] + assert api_response._iter_fields(b"\x80") == [] + assert api_response._iter_fields(_varint(1 << 3)) == [] + assert api_response._iter_fields(_varint((1 << 3) | 1)) == [] + assert api_response._iter_fields(_fixed64_field(1)) == [(1, 1, b"")] + assert api_response._iter_fields(_varint((1 << 3) | 2)) == [] + assert api_response._iter_fields(_varint((1 << 3) | 2) + _varint(5) + b"ab") == [] + assert api_response._iter_fields(_varint((1 << 3) | 5)) == [] + assert api_response._iter_fields(_fixed32_field(1)) == [(1, 5, b"")] + + +def test_raw_varint_reader_rejects_invalid_values() -> None: + """Verify low-level varint failures remain explicit for callers.""" + with pytest.raises(ValueError, match="varint too long"): + api_response._read_varint(b"\x80" * 10, 0) + + with pytest.raises(ValueError, match="truncated varint"): + api_response._read_varint(b"\x80", 0) diff --git a/tests/test_application_discovery.py b/tests/test_application_discovery.py new file mode 100644 index 0000000..3aa53a8 --- /dev/null +++ b/tests/test_application_discovery.py @@ -0,0 +1,113 @@ +"""Unit tests for application title-discovery use cases.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import pytest + +from mloader.application import discovery +from mloader.application.errors import DiscoveryError +from mloader.application.ports import TitleDiscoveryGateway +from mloader.application.requests import build_discovery_request +from mloader.errors import APIResponseError + + +def test_verify_discovery_flags_rejects_list_only_without_all() -> None: + """Verify list-only validation fails when all-mode is disabled.""" + message = discovery.verify_discovery_flags( + download_all_titles=False, + list_only=True, + languages=(), + ) + + assert message == "--list-only requires --all." + + +def test_verify_discovery_flags_rejects_language_without_all() -> None: + """Verify language validation fails when all-mode is disabled.""" + message = discovery.verify_discovery_flags( + download_all_titles=False, + list_only=False, + languages=("english",), + ) + + assert message == "--language requires --all." + + +def test_verify_discovery_flags_accepts_all_mode() -> None: + """Verify discovery flag validation allows all-mode combinations.""" + message = discovery.verify_discovery_flags( + download_all_titles=True, + list_only=True, + languages=("english",), + ) + + assert message is None + + +def test_discover_title_ids_requires_usable_api_payload_for_language_filters() -> None: + """Verify language-filter discovery stops on API payload/schema errors.""" + + class PayloadErrorGateway(TitleDiscoveryGateway): + def parse_language_filters(self, languages: Sequence[str]) -> set[int] | None: + del languages + return {0} + + def collect_title_ids_from_api( + self, + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + capture_api_dir: str | None = None, + ) -> list[int]: + del ( + title_index_endpoint, + id_length, + allowed_languages, + request_timeout, + capture_api_dir, + ) + raise APIResponseError("schema drift", kind="unknown") + + def collect_title_ids( + self, + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + ) -> list[int]: + del pages, id_length, request_timeout + raise AssertionError("static fallback must not run with language filters") + + def collect_title_ids_with_browser( + self, + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, + ) -> list[int]: + del pages, id_length, timeout_ms + raise AssertionError("browser fallback must not run with language filters") + + request = build_discovery_request( + pages=("https://example.com",), + title_index_endpoint="https://api.example/allV2", + id_length=6, + languages=("english",), + browser_fallback=True, + ) + + with pytest.raises(DiscoveryError, match="API response was unusable"): + discovery.discover_title_ids( + request, + gateway=PayloadErrorGateway(), + ) + + +def test_format_helpers_return_expected_cli_strings() -> None: + """Verify helper formatters produce deterministic human-facing output.""" + assert discovery.summarize_discovery([1, 2, 3]) == "Discovered 3 title ID(s)." + assert discovery.format_discovered_ids([100001, 100002]) == "100001 100002" diff --git a/tests/test_application_downloads.py b/tests/test_application_downloads.py new file mode 100644 index 0000000..15b8e32 --- /dev/null +++ b/tests/test_application_downloads.py @@ -0,0 +1,301 @@ +"""Unit tests for application download use cases.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from mloader.application import downloads +from mloader.application.errors import DownloadInterrupted, ExternalDependencyError +from mloader.domain.manga import Chapter, Title +from mloader.domain.requests import ( + ApiOutputFormat, + DownloadRequest, + DownloadSummary, +) +from mloader.types import ExporterFactoryLike +from tests.cli_fakes import ( + APIResponseErrorDownloadRuntime, + ApplicationRecordingDownloadRuntime, + ApplicationInterruptedDownloadRuntime, + NoneReturningDownloadRuntime, + RecordingCbzExporter, + RecordingExporter, + RecordingPdfExporter, + RecordingRawExporter, + RequestFailingDownloadRuntime, +) + + +def _build_request(*, raw: bool = False, output_format: ApiOutputFormat = "cbz") -> DownloadRequest: + """Build a deterministic download request for helper tests.""" + return DownloadRequest( + out_dir="/tmp/downloads", + raw=raw, + output_format=("pdf" if output_format == "pdf" else "cbz"), + capture_api_dir="/tmp/capture", + quality="high", + split=True, + begin=1, + end=5, + last=True, + chapter_title=True, + chapter_subdir=False, + meta=True, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=frozenset({10, 11}), + chapter_ids=frozenset({1024959}), + titles=frozenset({100001}), + ) + + +def test_resolve_exporter_prefers_raw_over_requested_format() -> None: + """Verify raw mode always forces raw exporter selection.""" + request = _build_request(raw=True, output_format="pdf") + + exporter, effective_format = downloads.resolve_exporter( + request, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert exporter is RecordingRawExporter + assert effective_format == "raw" + + +def test_resolve_exporter_selects_pdf_when_requested() -> None: + """Verify non-raw PDF requests resolve to PDF exporter.""" + request = _build_request(raw=False, output_format="pdf") + + exporter, effective_format = downloads.resolve_exporter( + request, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert exporter is RecordingPdfExporter + assert effective_format == "pdf" + + +def test_resolve_exporter_falls_back_to_cbz() -> None: + """Verify non-raw non-PDF requests resolve to CBZ exporter.""" + request = _build_request(raw=False, output_format="cbz") + + exporter, effective_format = downloads.resolve_exporter( + request, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert exporter is RecordingCbzExporter + assert effective_format == "cbz" + + +def test_execute_download_wires_loader_and_download_targets() -> None: + """Verify execute_download builds loader and forwards normalized targets.""" + ApplicationRecordingDownloadRuntime.init_args = None + ApplicationRecordingDownloadRuntime.download_args = None + RecordingExporter.calls = [] + request = _build_request(raw=False, output_format="pdf") + + summary = downloads.execute_download( + request, + loader_factory=ApplicationRecordingDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert ApplicationRecordingDownloadRuntime.init_args is not None + assert ApplicationRecordingDownloadRuntime.download_args is not None + assert ApplicationRecordingDownloadRuntime.init_args["output_format"] == "pdf" + assert ApplicationRecordingDownloadRuntime.init_args["capture_api_dir"] == "/tmp/capture" + assert ApplicationRecordingDownloadRuntime.init_args["resume"] is True + assert ApplicationRecordingDownloadRuntime.init_args["manifest_reset"] is False + assert ApplicationRecordingDownloadRuntime.init_args["cover"] is False + assert ApplicationRecordingDownloadRuntime.init_args["cover_format"] == "png" + assert ApplicationRecordingDownloadRuntime.init_args["quality"] == "high" + title = Title( + title_id=1, + name="Title", + author="Author", + portrait_image_url="", + landscape_image_url="", + language=0, + ) + chapter = Chapter(title_id=1, chapter_id=1, name="#1", sub_title="One", thumbnail_url="") + next_chapter = Chapter(title_id=1, chapter_id=2, name="#2", sub_title="Two", thumbnail_url="") + exporter_factory = cast( + ExporterFactoryLike, ApplicationRecordingDownloadRuntime.init_args["exporter_factory"] + ) + exporter = exporter_factory( + title=title, + chapter=chapter, + next_chapter=next_chapter, + ) + assert isinstance(exporter, RecordingExporter) + assert RecordingExporter.calls == [ + { + "destination": "/tmp/downloads", + "title": title, + "chapter": chapter, + "next_chapter": next_chapter, + "add_chapter_title": True, + "add_chapter_subdir": False, + "add_language_to_chapter_name": False, + } + ] + assert ApplicationRecordingDownloadRuntime.download_args["title_ids"] == frozenset({100001}) + assert ApplicationRecordingDownloadRuntime.download_args["chapter_numbers"] == frozenset( + {10, 11} + ) + assert ApplicationRecordingDownloadRuntime.download_args["chapter_ids"] == frozenset({1024959}) + assert ApplicationRecordingDownloadRuntime.download_args["min_chapter"] == 1 + assert ApplicationRecordingDownloadRuntime.download_args["max_chapter"] == 5 + assert ApplicationRecordingDownloadRuntime.download_args["last_chapter"] is True + assert summary == DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=0, + failed_chapter_ids=(), + ) + + +def test_execute_download_omits_empty_target_filters() -> None: + """Verify execute_download forwards None when target sets are empty.""" + ApplicationRecordingDownloadRuntime.download_args = None + request = DownloadRequest( + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=frozenset(), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + downloads.execute_download( + request, + loader_factory=ApplicationRecordingDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert ApplicationRecordingDownloadRuntime.download_args is not None + assert ApplicationRecordingDownloadRuntime.download_args["title_ids"] is None + assert ApplicationRecordingDownloadRuntime.download_args["chapter_numbers"] is None + assert ApplicationRecordingDownloadRuntime.download_args["chapter_ids"] is None + + +def test_execute_download_wraps_request_errors_as_external_dependency_failure() -> None: + """Verify request-layer failures are normalized into application external errors.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises(ExternalDependencyError, match="Download request failed: network"): + downloads.execute_download( + request, + loader_factory=RequestFailingDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + +def test_execute_download_wraps_api_payload_errors_as_external_dependency_failure() -> None: + """Verify invalid API payload failures map to application external errors.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises( + ExternalDependencyError, + match="Download request failed: MangaPlus API returned no manga_viewer payload.", + ): + downloads.execute_download( + request, + loader_factory=APIResponseErrorDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + +def test_execute_download_wraps_interrupt_as_application_interrupt() -> None: + """Verify interrupted downloader runs are normalized with partial summary.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises(DownloadInterrupted) as interrupted: + downloads.execute_download( + request, + loader_factory=ApplicationInterruptedDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert interrupted.value.summary == DownloadSummary( + downloaded=3, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(77,), + ) + + +def test_execute_download_falls_back_when_loader_returns_non_summary() -> None: + """Verify execute_download normalizes non-summary loader returns.""" + request = _build_request(raw=False, output_format="cbz") + + summary = downloads.execute_download( + request, + loader_factory=NoneReturningDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingCbzExporter, + ) + + assert summary == DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def test_to_chapter_id_debug_map_includes_expected_keys() -> None: + """Verify debug-map helper exposes stable low-cardinality fields.""" + request = _build_request(raw=False, output_format="cbz") + debug_map = downloads.to_chapter_id_debug_map(request) + + assert debug_map == { + "target_titles": 1, + "target_chapters": 2, + "target_chapter_ids": 1, + "begin": 1, + "end": 5, + "raw": False, + "format": "cbz", + "cover": False, + "cover_format": "png", + "resume": True, + "manifest_reset": False, + "capture_api": True, + "run_report": False, + } diff --git a/tests/test_application_requests.py b/tests/test_application_requests.py new file mode 100644 index 0000000..8c8cf79 --- /dev/null +++ b/tests/test_application_requests.py @@ -0,0 +1,76 @@ +"""Unit tests for application request construction.""" + +from __future__ import annotations + +import pytest + +from mloader.application import requests as request_builders + + +def test_build_request_helpers_create_immutable_domain_models() -> None: + """Verify request builder helpers normalize and freeze collection inputs.""" + download_request = request_builders.build_download_request( + out_dir="/tmp/downloads", + raw=False, + output_format="pdf", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=True, + cover_format="WEBP", + resume=False, + manifest_reset=True, + chapters={5, 5}, + chapter_ids={1024959, 1024959}, + titles={100010, 100010}, + ) + discovery_request = request_builders.build_discovery_request( + pages=("https://example.com",), + title_index_endpoint="https://api.example/allV2", + id_length=6, + languages=("english",), + browser_fallback=True, + ) + + assert download_request.output_format == "pdf" + assert download_request.chapters == frozenset({5}) + assert download_request.chapter_ids == frozenset({1024959}) + assert download_request.titles == frozenset({100010}) + assert download_request.cover is True + assert download_request.cover_format == "webp" + assert download_request.resume is False + assert download_request.manifest_reset is True + assert discovery_request.title_index_endpoint == "https://api.example/allV2" + assert discovery_request.languages == ("english",) + + +def test_build_download_request_rejects_unsupported_cover_format() -> None: + """Verify application request construction validates cover format values.""" + with pytest.raises(ValueError, match="Unsupported cover format: bmp"): + request_builders.build_download_request( + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=True, + cover_format="bmp", + resume=True, + manifest_reset=False, + chapters=None, + chapter_ids=None, + titles=None, + ) diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..0bbdd4b --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,151 @@ +"""Tests for API payload capture persistence helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from mloader.infrastructure.mangaplus import capture as capture_module + + +def _varint(value: int) -> bytes: + """Encode a protobuf varint for local error-envelope fixtures.""" + parts: list[int] = [] + while True: + byte = value & 0x7F + value >>= 7 + if value: + parts.append(byte | 0x80) + continue + parts.append(byte) + return bytes(parts) + + +def _length_delimited_field(field_number: int, value: bytes) -> bytes: + """Encode one length-delimited protobuf field.""" + return _varint((field_number << 3) | 2) + _varint(len(value)) + value + + +def _string_field(field_number: int, value: str) -> bytes: + """Encode one protobuf string field.""" + return _length_delimited_field(field_number, value.encode("utf-8")) + + +def _api_error_payload() -> bytes: + """Build a minimal MangaPlus application-error envelope.""" + localized_error = _string_field(1, "Invalid Parameter") + _string_field( + 2, + "There are issues connecting to Manga+. Please try again later.(10511)", + ) + error_result = _length_delimited_field(2, localized_error) + return _length_delimited_field(2, error_result) + + +def test_sanitize_filename_replaces_unsafe_characters() -> None: + """Verify filename sanitizer removes unsupported filesystem characters.""" + assert capture_module._sanitize_filename(" chapter:/1 ") == "chapter_1" + assert capture_module._sanitize_filename("...") == "capture" + + +def test_redact_params_masks_sensitive_keys() -> None: + """Verify sensitive query values are replaced in metadata output.""" + params = {"a": 1, "secret": "abc", "Token": "xyz"} + redacted = capture_module._redact_params(params) + + assert redacted["a"] == 1 + assert redacted["secret"] == "***REDACTED***" + assert redacted["Token"] == "***REDACTED***" + + +def test_payload_capture_writes_raw_metadata_and_parsed_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify capture mode writes protobuf bytes, metadata, and parsed JSON.""" + + class FakeResponse: + @staticmethod + def FromString(_payload: bytes) -> object: + return object() + + monkeypatch.setattr(capture_module, "Response", FakeResponse) + monkeypatch.setattr(capture_module, "MessageToDict", lambda _msg, **_kwargs: {"ok": True}) + + recorder = capture_module.APIPayloadCapture(tmp_path) + recorder.capture( + endpoint="manga_viewer", + identifier=123, + url="https://api.example/api/manga_viewer", + params={"chapter_id": 123, "secret": "hidden"}, + response_content=b"\x01\x02", + ) + + raw_files = sorted(tmp_path.glob("*.pb")) + meta_files = sorted(tmp_path.glob("*.meta.json")) + parsed_files = sorted(tmp_path.glob("*.response.json")) + + assert len(raw_files) == 1 + assert len(meta_files) == 1 + assert len(parsed_files) == 1 + + metadata = json.loads(meta_files[0].read_text(encoding="utf-8")) + assert metadata["endpoint"] == "manga_viewer" + assert metadata["identifier"] == "123" + assert metadata["params"]["secret"] == "***REDACTED***" + assert metadata["payload_classification"] == "unknown" + assert metadata["parsed_payload_file"] == parsed_files[0].name + assert raw_files[0].read_bytes() == b"\x01\x02" + + parsed = json.loads(parsed_files[0].read_text(encoding="utf-8")) + assert parsed == {"ok": True} + + +def test_payload_capture_records_parse_error_without_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify parse failures still keep raw payload and metadata.""" + + class FakeResponse: + @staticmethod + def FromString(_payload: bytes) -> object: + raise ValueError("bad payload") + + monkeypatch.setattr(capture_module, "Response", FakeResponse) + + recorder = capture_module.APIPayloadCapture(tmp_path) + recorder.capture( + endpoint="title_detailV3", + identifier="foo", + url="https://api.example/api/title_detailV3", + params={"title_id": 7}, + response_content=b"\x09", + ) + + meta_files = sorted(tmp_path.glob("*.meta.json")) + assert len(meta_files) == 1 + assert list(tmp_path.glob("*.response.json")) == [] + + metadata = json.loads(meta_files[0].read_text(encoding="utf-8")) + assert metadata["endpoint"] == "title_detailV3" + assert "parsed_payload_error" in metadata + + +def test_payload_capture_records_api_error_metadata(tmp_path: Path) -> None: + """Verify captured MangaPlus error envelopes are described in metadata.""" + recorder = capture_module.APIPayloadCapture(tmp_path) + recorder.capture( + endpoint="title_index", + identifier="all", + url="https://api.example/api/title_list/allV2", + params={"secret": "hidden"}, + response_content=_api_error_payload(), + ) + + metadata = json.loads(next(tmp_path.glob("*.meta.json")).read_text(encoding="utf-8")) + + assert metadata["payload_classification"] == "api_error" + assert metadata["api_error"]["title"] == "Invalid Parameter" + assert metadata["api_error"]["code"] == "10511" diff --git a/tests/test_capture_replay.py b/tests/test_capture_replay.py new file mode 100644 index 0000000..d7a1b6e --- /dev/null +++ b/tests/test_capture_replay.py @@ -0,0 +1,417 @@ +"""Replay tests using real captured API payload fixtures.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlparse + +import pytest + +from mloader.domain.planning import build_download_plan +from mloader.infrastructure.mangaplus import parsing +from mloader.manga_loader.chapter_planning import ChapterPlanner +from mloader.manga_loader.init import MangaLoader +from mloader.types import ChapterLike, ExporterLike, PageIndex, ResponseLike, SessionLike, TitleLike +from mloader.utils import escape_path + +FIXTURE_CAPTURE_DIR = Path(__file__).parent / "fixtures" / "api_captures" / "baseline" +LOCAL_CAPTURE_DIR = Path("capture") + + +def _as_dict(value: object, context: str) -> dict[str, Any]: + """Return ``value`` as a dictionary or raise a descriptive assertion error.""" + if not isinstance(value, dict): + raise AssertionError(f"Expected dict for {context}, got {type(value).__name__}") + return cast(dict[str, Any], value) + + +def _as_list(value: object, context: str) -> list[Any]: + """Return ``value`` as a list or raise a descriptive assertion error.""" + if not isinstance(value, list): + raise AssertionError(f"Expected list for {context}, got {type(value).__name__}") + return value + + +def _load_json(path: Path) -> dict[str, Any]: + """Load and return JSON object from ``path``.""" + return _as_dict(json.loads(path.read_text(encoding="utf-8")), str(path)) + + +def _collect_capture_records( + capture_dir: Path, +) -> list[tuple[str, dict[str, Any], dict[str, Any] | None]]: + """Collect capture metadata/response records from ``capture_dir``.""" + records: list[tuple[str, dict[str, Any], dict[str, Any] | None]] = [] + for meta_path in sorted(capture_dir.glob("*.meta.json")): + stem = meta_path.name.removesuffix(".meta.json") + meta = _load_json(meta_path) + response_path = capture_dir / f"{stem}.response.json" + if response_path.exists(): + response: dict[str, Any] | None = _load_json(response_path) + elif meta.get("payload_classification") == "api_error": + response = None + else: + raise AssertionError(f"Missing response JSON for capture stem: {stem}") + records.append((stem, meta, response)) + return records + + +def _schema_signature( + meta: dict[str, Any], + response: dict[str, Any] | None, +) -> dict[str, object]: + """Build a schema signature from capture metadata and parsed response JSON.""" + endpoint = str(meta["endpoint"]) + signature: dict[str, object] = { + "endpoint": endpoint, + "url_path": urlparse(str(meta["url"])).path, + "meta_keys": sorted(meta.keys()), + "param_keys": sorted(_as_dict(meta["params"], "meta.params").keys()), + } + + if meta.get("payload_classification") == "api_error": + api_error = _as_dict(meta.get("api_error"), "meta.api_error") + signature["payload_classification"] = "api_error" + signature["api_error_code"] = api_error.get("code") + signature["api_error_language"] = api_error.get("language") + signature["api_error_title"] = api_error.get("title") + return signature + + if response is None: + raise AssertionError(f"Missing response JSON for successful endpoint: {endpoint}") + + success = _as_dict(response["success"], "response.success") + signature["success_keys"] = sorted(success.keys()) + + if endpoint == "manga_viewer": + viewer = _as_dict(success["manga_viewer"], "response.success.manga_viewer") + signature["payload_keys"] = sorted(viewer.keys()) + + pages = _as_list(viewer.get("pages", []), "response.success.manga_viewer.pages") + if meta.get("expected_runtime_error") == "subscription_required": + signature["payload_state"] = "subscription_required" + return signature + + signature["payload_state"] = "pages" + first_page = _as_dict(pages[0], "response.success.manga_viewer.pages[0]") + last_page = _as_dict(pages[-1], "response.success.manga_viewer.pages[-1]") + signature["first_page_keys"] = sorted(first_page.keys()) + signature["last_page_keys"] = sorted(last_page.keys()) + signature["manga_page_keys"] = sorted( + _as_dict( + first_page["manga_page"], "response.success.manga_viewer.pages[0].manga_page" + ).keys() + ) + signature["last_page_payload_keys"] = sorted( + _as_dict( + last_page["last_page"], "response.success.manga_viewer.pages[-1].last_page" + ).keys() + ) + return signature + + if endpoint == "title_detailV3": + title_detail = _as_dict(success["title_detail_view"], "response.success.title_detail_view") + signature["payload_keys"] = sorted(title_detail.keys()) + signature["title_keys"] = sorted( + _as_dict(title_detail["title"], "response.success.title_detail_view.title").keys() + ) + + chapter_groups = title_detail.get("chapter_list_group") + if chapter_groups: + grouped_chapters = _as_list( + chapter_groups, + "response.success.title_detail_view.chapter_list_group", + ) + first_group = _as_dict( + grouped_chapters[0], + "response.success.title_detail_view.chapter_list_group[0]", + ) + signature["chapter_source"] = "chapter_list_group" + signature["chapter_group_keys"] = sorted(first_group.keys()) + + first_chapter_list = _as_list( + first_group["first_chapter_list"], "chapter_group.first_chapter_list" + ) + first_chapter = _as_dict(first_chapter_list[0], "chapter_group.first_chapter_list[0]") + signature["chapter_keys"] = sorted(first_chapter.keys()) + return signature + + flat_chapters = _as_list( + title_detail.get("chapter_list", []), + "response.success.title_detail_view.chapter_list", + ) + signature["chapter_source"] = "chapter_list" + first_chapter = _as_dict(flat_chapters[0], "title_detail.chapter_list[0]") + signature["chapter_keys"] = sorted(first_chapter.keys()) + return signature + + if endpoint == "title_index": + all_titles = _as_dict(success["all_titles_view"], "response.success.all_titles_view") + signature["payload_keys"] = sorted(all_titles.keys()) + title_groups = _as_list( + all_titles["title_groups"], + "response.success.all_titles_view.title_groups", + ) + first_group = _as_dict(title_groups[0], "all_titles_view.title_groups[0]") + signature["title_group_keys"] = sorted(first_group.keys()) + titles = _as_list(first_group["titles"], "all_titles_view.title_groups[0].titles") + first_title = _as_dict(titles[0], "all_titles_view.title_groups[0].titles[0]") + signature["title_keys"] = sorted(first_title.keys()) + return signature + + raise AssertionError(f"Unexpected endpoint in capture metadata: {endpoint}") + + +def test_title_detail_fixture_replays_into_chapter_planner() -> None: + """Validate chapter planning logic against a real captured title-detail payload.""" + raw_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + title_detail = parsing.parse_title_detail_response(raw_payload) + + assert title_detail.title.title_id == 100010 + assert title_detail.title.name == "Dr. STONE" + + chapter_data = ChapterPlanner.extract_chapter_data(title_detail, lambda value: value) + assert len(chapter_data) == 236 + + chapter_2 = ChapterPlanner.find_chapter_by_id(title_detail, 1000311) + assert chapter_2 is not None + expected_existing = ChapterPlanner.build_expected_filename( + escape_path(title_detail.title.name).title(), + chapter_2, + chapter_2.sub_title, + ) + + result = ChapterPlanner.filter_chapters_to_download( + chapter_data=chapter_data, + title_detail=title_detail, + existing_files=[expected_existing], + requested_chapter_ids={1000311, 1000312}, + ) + assert result == [1000312] + + +def test_capture_replay_dto_plan_and_filename_contract() -> None: + """Replay fixtures through DTO mapping, domain planning, and filename filtering.""" + title_detail = parsing.parse_title_detail_response( + (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + ) + viewer_by_id = { + 1000311: parsing.parse_manga_viewer_response( + (FIXTURE_CAPTURE_DIR / "0002_manga_viewer_1000311.pb").read_bytes() + ), + } + + plan = build_download_plan( + title_ids={100010}, + chapter_numbers={3}, + chapter_ids={1000311}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda title_id: ( + title_detail + if title_id == 100010 + else pytest.fail(f"Unexpected title load: {title_id}") + ), + load_viewer=lambda chapter_id: viewer_by_id[chapter_id], + ) + + assert plan.title_count == 1 + title_plan = plan.title_plans[0] + assert title_plan.title_detail.__class__.__module__ == "mloader.domain.manga" + assert title_plan.chapter_ids == frozenset({1000311, 1000312}) + + chapter_data = ChapterPlanner.extract_chapter_data(title_plan.title_detail, escape_path) + filename_by_id = { + chapter.chapter_id: ChapterPlanner.build_expected_filename( + escape_path(title_plan.title_detail.title.name).title(), + chapter, + chapter_data[chapter.chapter_id].sub_title, + ) + for chapter in title_plan.selected_chapters + } + + assert filename_by_id == { + 1000311: "Dr Stone - 002 - Z 2 Fantasy vs Science", + 1000312: "Dr Stone - 003 - Z 3 King of the Stone World", + } + assert ChapterPlanner.filter_chapters_to_download( + chapter_data=chapter_data, + title_detail=title_plan.title_detail, + existing_files=[filename_by_id[1000311]], + requested_chapter_ids=title_plan.chapter_ids, + ) == [1000312] + + +@pytest.mark.parametrize( + ("fixture_name", "chapter_id", "next_chapter_id"), + [ + ("0002_manga_viewer_1000311.pb", 1000311, 1000312), + ("0003_manga_viewer_1000312.pb", 1000312, 1000313), + ], +) +def test_manga_viewer_fixtures_replay_consistently( + fixture_name: str, + chapter_id: int, + next_chapter_id: int, +) -> None: + """Validate real manga-viewer fixture parsing for chapter linkage.""" + raw_payload = (FIXTURE_CAPTURE_DIR / fixture_name).read_bytes() + viewer = parsing.parse_manga_viewer_response(raw_payload) + + assert viewer.title_id == 100010 + assert viewer.chapter_id == chapter_id + assert len(viewer.pages) > 20 + assert len(viewer.chapters) == 3 + + last_page = viewer.last_page + assert last_page is not None + assert last_page.current_chapter.chapter_id == chapter_id + assert last_page.next_chapter is not None + assert last_page.next_chapter.chapter_id == next_chapter_id + + +def test_local_capture_schema_matches_baseline_fixture() -> None: + """Compare local capture schema against baseline fixture schema signatures.""" + baseline_records = _collect_capture_records(FIXTURE_CAPTURE_DIR) + assert baseline_records + + baseline_by_endpoint: dict[str, set[str]] = {} + for _stem, meta, response in baseline_records: + signature = _schema_signature(meta, response) + endpoint = str(signature["endpoint"]) + baseline_by_endpoint.setdefault(endpoint, set()).add(json.dumps(signature, sort_keys=True)) + + if not LOCAL_CAPTURE_DIR.exists(): + pytest.skip("No local capture directory exists; skipping schema drift check.") + + local_records = _collect_capture_records(LOCAL_CAPTURE_DIR) + assert local_records, "Capture directory exists but has no capture records." + + for stem, meta, response in local_records: + local_signature = _schema_signature(meta, response) + endpoint = str(local_signature["endpoint"]) + assert endpoint in baseline_by_endpoint, ( + f"Unknown endpoint in local capture '{stem}': {endpoint}" + ) + signature_payload = json.dumps(local_signature, sort_keys=True) + assert signature_payload in baseline_by_endpoint[endpoint], ( + f"Schema drift detected for local capture '{stem}' endpoint '{endpoint}'." + ) + + +def test_full_downloader_replay_with_fixture_payloads( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Replay title and viewer fixtures through loader download orchestration.""" + title_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + viewer_payloads = { + 1000311: (FIXTURE_CAPTURE_DIR / "0002_manga_viewer_1000311.pb").read_bytes(), + 1000312: (FIXTURE_CAPTURE_DIR / "0003_manga_viewer_1000312.pb").read_bytes(), + } + + class ReplayResponse(ResponseLike): + """Response double wrapping one captured protobuf payload.""" + + def __init__(self, content: bytes) -> None: + self.content = content + + def raise_for_status(self) -> None: + """Fixture payloads are treated as successful HTTP responses.""" + + class ReplaySession(SessionLike): + """Session double that replays captured protobuf responses by endpoint and ID.""" + + def __init__(self) -> None: + """Initialize mutable headers and no-op transport hooks.""" + self.headers: dict[str, str] = {} + + def mount(self, prefix: str, adapter: object) -> None: + """Ignore adapter mounts; they are irrelevant for fixture replay.""" + del prefix, adapter + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> ResponseLike: + """Return fixture payload bytes matching requested endpoint and identifier.""" + del timeout + params = params or {} + if url.endswith("/api/title_detailV3"): + return ReplayResponse(title_payload) + if url.endswith("/api/manga_viewer"): + chapter_id = int(str(params["chapter_id"])) + return ReplayResponse(viewer_payloads[chapter_id]) + raise AssertionError(f"Unexpected replay URL: {url}") + + class ReplayExporter(ExporterLike): + """Exporter double recording page writes and emitting output markers.""" + + def __init__(self, path: Path) -> None: + self.path = path + self.images = 0 + + def skip_image(self, index: PageIndex) -> bool: + del index + return False + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + del image_data, index + self.images += 1 + + def close(self) -> None: + self.path.write_bytes(b"ok") + + class ReplayExporterFactory: + """Factory double satisfying the runtime exporter-factory protocol.""" + + def __init__(self) -> None: + self.created: list[ReplayExporter] = [] + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + del title, next_chapter + exporter = ReplayExporter(tmp_path / f"{chapter.chapter_id}.cbz") + self.created.append(exporter) + return exporter + + exporter_factory = ReplayExporterFactory() + + loader = MangaLoader( + exporter=exporter_factory, + quality="high", + split=False, + meta=False, + destination=str(tmp_path), + output_format="cbz", + session=ReplaySession(), + ) + monkeypatch.setattr( + loader._runtime.services.page_image_service, + "fetch_page_image", + lambda _page, *, download_image, decrypt_image: b"img", + ) + + summary = loader.download( + title_ids=None, + chapter_ids={1000311, 1000312}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + ) + + assert summary.downloaded == 2 + assert summary.failed == 0 + assert len(exporter_factory.created) == 2 + assert all(exporter.images > 10 for exporter in exporter_factory.created) diff --git a/tests/test_capture_verify_baseline.py b/tests/test_capture_verify_baseline.py new file mode 100644 index 0000000..da58eed --- /dev/null +++ b/tests/test_capture_verify_baseline.py @@ -0,0 +1,309 @@ +"""Tests for capture verification against required response fields.""" + +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +import pytest + +from mloader.infrastructure.mangaplus.capture_metadata import ( + CaptureVerificationError, + load_metadata, +) +from mloader.infrastructure.mangaplus.capture_verify import ( + verify_capture_schema_against_baseline, + verify_capture_schema, +) +from mloader.response_pb2 import Response +from tests.capture_verify_helpers import ( + FIXTURE_CAPTURE_DIR, + api_error_payload, + copy_fixture_set, + update_payload_metadata, +) + + +def test_verify_capture_schema_with_real_fixture_set() -> None: + """Verify baseline fixture set passes schema verification.""" + summary = verify_capture_schema(FIXTURE_CAPTURE_DIR) + + assert summary.total_records == 8 + assert summary.endpoint_counts == { + "manga_viewer": 4, + "title_detailV3": 2, + "title_index": 2, + } + + +def test_baseline_fixture_set_covers_api_drift_cases() -> None: + """Verify checked-in baseline captures include the known MangaPlus drift shapes.""" + fixture_names = {path.name for path in FIXTURE_CAPTURE_DIR.glob("*.meta.json")} + assert "0004_title_index_all.meta.json" in fixture_names + assert "0005_title_detailV3_flat_chapter_list.meta.json" in fixture_names + assert "0006_title_index_api_error.meta.json" in fixture_names + assert "0007_manga_viewer_subscription_required.meta.json" in fixture_names + assert "0008_manga_viewer_encrypted_page.meta.json" in fixture_names + + flat_title_detail = json.loads( + (FIXTURE_CAPTURE_DIR / "0005_title_detailV3_flat_chapter_list.response.json").read_text( + encoding="utf-8" + ) + ) + assert ( + flat_title_detail["success"]["title_detail_view"]["chapter_list"][0]["chapter_id"] + == 1024974 + ) + + encrypted_viewer = json.loads( + (FIXTURE_CAPTURE_DIR / "0008_manga_viewer_encrypted_page.response.json").read_text( + encoding="utf-8" + ) + ) + encrypted_page = encrypted_viewer["success"]["manga_viewer"]["pages"][0]["manga_page"] + assert encrypted_page["encryption_key"] == "00112233445566778899aabbccddeeff" + + subscription_meta = json.loads( + (FIXTURE_CAPTURE_DIR / "0007_manga_viewer_subscription_required.meta.json").read_text( + encoding="utf-8" + ) + ) + assert subscription_meta["expected_runtime_error"] == "subscription_required" + + +def test_verify_capture_schema_accepts_api_error_envelope(tmp_path: Path) -> None: + """Verify captured MangaPlus application errors are first-class fixtures.""" + payload = api_error_payload() + payload_path = tmp_path / "0001_title_index_all.pb" + payload_path.write_bytes(payload) + metadata = { + "endpoint": "title_index", + "identifier": "all", + "url": "https://jumpg-webapi.tokyo-cdn.com/api/title_list/allV2", + "params": {"id_length": 6}, + "raw_payload_file": payload_path.name, + "payload_size_bytes": len(payload), + "payload_sha256": sha256(payload).hexdigest(), + "payload_classification": "api_error", + "api_error": { + "title": "Invalid Parameter", + "body": "There are issues connecting to Manga+. Please try again later.(10511)", + "code": "10511", + "language": 0, + }, + } + (tmp_path / "0001_title_index_all.meta.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + summary = verify_capture_schema(tmp_path) + + assert summary.total_records == 1 + assert summary.endpoint_counts == {"title_index": 1} + + +def test_verify_capture_schema_accepts_title_index_success_payload(tmp_path: Path) -> None: + """Verify title-index success captures are validated and counted.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + group.group_name = "weekly" + title = group.titles.add() + title.title_id = 100001 + title.name = "Demo" + payload = parsed.SerializeToString() + + payload_path = tmp_path / "0001_title_index_all.pb" + payload_path.write_bytes(payload) + metadata = { + "endpoint": "title_index", + "identifier": "all", + "url": "https://jumpg-webapi.tokyo-cdn.com/api/title_list/allV2", + "params": {"id_length": 6}, + "raw_payload_file": payload_path.name, + "payload_size_bytes": len(payload), + "payload_sha256": sha256(payload).hexdigest(), + } + (tmp_path / "0001_title_index_all.meta.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + summary = verify_capture_schema(tmp_path) + + assert summary.total_records == 1 + assert summary.endpoint_counts == {"title_index": 1} + + +def test_verify_capture_schema_against_baseline_with_real_fixture_set() -> None: + """Verify baseline comparison passes for matching capture and baseline sets.""" + summary = verify_capture_schema_against_baseline(FIXTURE_CAPTURE_DIR, FIXTURE_CAPTURE_DIR) + assert summary.total_records == 8 + + +def test_verify_capture_schema_against_baseline_detects_drift(tmp_path: Path) -> None: + """Verify baseline comparison fails when capture signature keys drift.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["params"]["extra_param"] = "1" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Schema drift detected"): + verify_capture_schema_against_baseline(tmp_path, FIXTURE_CAPTURE_DIR) + + +def test_verify_capture_schema_against_baseline_rejects_unknown_capture_endpoint( + tmp_path: Path, +) -> None: + """Verify comparison fails when capture has endpoint absent from baseline set.""" + capture_dir = tmp_path / "capture" + baseline_dir = tmp_path / "baseline" + copy_fixture_set(capture_dir) + baseline_dir.mkdir(parents=True, exist_ok=True) + for fixture_file in FIXTURE_CAPTURE_DIR.glob("0001_title_detailV3_100010.*"): + (baseline_dir / fixture_file.name).write_bytes(fixture_file.read_bytes()) + + with pytest.raises(CaptureVerificationError, match="Unknown endpoint"): + verify_capture_schema_against_baseline(capture_dir, baseline_dir) + + +def test_verify_capture_schema_fails_without_metadata_files(tmp_path: Path) -> None: + """Verify verifier fails when capture directory has no metadata records.""" + with pytest.raises(CaptureVerificationError, match="No '\\*\\.meta\\.json' files found"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_unsupported_endpoint(tmp_path: Path) -> None: + """Verify verifier rejects unknown endpoint names in metadata.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["endpoint"] = "unsupported_endpoint" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Unsupported endpoint"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_size_mismatch(tmp_path: Path) -> None: + """Verify verifier catches payload size mismatches from metadata.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_size_bytes"] = 1 + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Payload size mismatch"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_sha_mismatch(tmp_path: Path) -> None: + """Verify verifier catches payload checksum mismatches from metadata.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_sha256"] = "0" * 64 + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Payload sha256 mismatch"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_manga_viewer_missing_pages(tmp_path: Path) -> None: + """Verify verifier rejects manga_viewer payloads with empty pages.""" + copy_fixture_set(tmp_path) + + payload_path = tmp_path / "0002_manga_viewer_1000311.pb" + parsed = Response.FromString(payload_path.read_bytes()) + parsed.success.manga_viewer.ClearField("pages") + mutated_payload = parsed.SerializeToString() + payload_path.write_bytes(mutated_payload) + + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + update_payload_metadata(meta_path, mutated_payload) + + with pytest.raises(CaptureVerificationError, match="No pages found in manga_viewer payload"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_title_detail_missing_chapter_lists( + tmp_path: Path, +) -> None: + """Verify verifier rejects title_detail payloads with no grouped or flat chapters.""" + copy_fixture_set(tmp_path) + + payload_path = tmp_path / "0001_title_detailV3_100010.pb" + parsed = Response.FromString(payload_path.read_bytes()) + parsed.success.title_detail_view.ClearField("chapter_list_group") + mutated_payload = parsed.SerializeToString() + payload_path.write_bytes(mutated_payload) + + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + update_payload_metadata(meta_path, mutated_payload) + + with pytest.raises(CaptureVerificationError, match="No chapter_list_group records"): + verify_capture_schema(tmp_path) + + +def test_load_metadata_rejects_non_dict_json(tmp_path: Path) -> None: + """Verify metadata loader requires a JSON object.""" + meta_path = tmp_path / "record.meta.json" + meta_path.write_text("[]", encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Metadata file is not an object"): + load_metadata(meta_path) + + +def test_verify_capture_schema_fails_for_missing_directory() -> None: + """Verify verifier rejects nonexistent capture directories.""" + with pytest.raises(CaptureVerificationError, match="Capture directory not found"): + verify_capture_schema("does-not-exist") + + +def test_verify_capture_schema_fails_for_missing_endpoint(tmp_path: Path) -> None: + """Verify verifier rejects metadata without endpoint values.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["endpoint"] = "" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Missing endpoint"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_missing_raw_payload_reference(tmp_path: Path) -> None: + """Verify verifier rejects metadata pointing to missing raw payload files.""" + copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["raw_payload_file"] = "missing.pb" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises( + CaptureVerificationError, match="Missing raw payload file referenced by metadata" + ): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_missing_success_envelope(tmp_path: Path) -> None: + """Verify verifier rejects payloads without a success envelope.""" + capture_name = "0001_title_detailV3_100010" + payload_path = tmp_path / f"{capture_name}.pb" + payload_path.write_bytes(b"") + + metadata = { + "endpoint": "title_detailV3", + "raw_payload_file": payload_path.name, + "payload_size_bytes": 0, + "payload_sha256": sha256(b"").hexdigest(), + } + (tmp_path / f"{capture_name}.meta.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + with pytest.raises(CaptureVerificationError, match="Missing success envelope"): + verify_capture_schema(tmp_path) diff --git a/tests/test_capture_verify_payloads.py b/tests/test_capture_verify_payloads.py new file mode 100644 index 0000000..3b19d54 --- /dev/null +++ b/tests/test_capture_verify_payloads.py @@ -0,0 +1,127 @@ +"""Direct payload verifier tests for MangaPlus capture verification.""" + +from __future__ import annotations + +import pytest + +from mloader.infrastructure.mangaplus.capture_metadata import CaptureVerificationError +from mloader.infrastructure.mangaplus.capture_payload_validation import ( + verify_manga_viewer_payload, + verify_title_detail_payload, + verify_title_index_payload, +) +from mloader.response_pb2 import Response + + +def test_verify_title_detail_payload_rejects_missing_title_detail() -> None: + """Verify title-detail payload verifier rejects missing payload branch.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + with pytest.raises(CaptureVerificationError, match="Missing success.title_detail_view"): + verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_detail_payload_rejects_missing_title_identity() -> None: + """Verify title-detail payload verifier requires title identity fields.""" + parsed = Response() + parsed.success.title_detail_view.chapter_list_group.add() + with pytest.raises(CaptureVerificationError, match="Missing required title identity fields"): + verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_detail_payload_rejects_empty_chapter_groups() -> None: + """Verify title-detail payload verifier rejects groups without chapters.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "T" + parsed.success.title_detail_view.chapter_list_group.add() + with pytest.raises( + CaptureVerificationError, match="No chapter entries found in chapter_list_group" + ): + verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_detail_payload_accepts_flat_mobile_chapter_list() -> None: + """Verify title-detail verifier accepts the mobile flat chapter list shape.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "T" + parsed.success.title_detail_view.chapter_list.add().chapter_id = 1024959 + + verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_index_payload_rejects_missing_title_index() -> None: + """Verify title-index verifier rejects missing all_titles_view branch.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + with pytest.raises(CaptureVerificationError, match="Missing success.all_titles_view"): + verify_title_index_payload(parsed, "sample") + + +def test_verify_title_index_payload_rejects_empty_groups() -> None: + """Verify title-index verifier requires at least one group.""" + parsed = Response() + parsed.success.all_titles_view.SetInParent() + with pytest.raises(CaptureVerificationError, match="No title_groups records"): + verify_title_index_payload(parsed, "sample") + + +def test_verify_title_index_payload_rejects_groups_without_titles() -> None: + """Verify title-index verifier requires at least one title entry.""" + parsed = Response() + parsed.success.all_titles_view.title_groups.add().group_name = "empty" + with pytest.raises(CaptureVerificationError, match="No title records found"): + verify_title_index_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_viewer() -> None: + """Verify manga-viewer payload verifier rejects missing payload branch.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "T" + with pytest.raises(CaptureVerificationError, match="Missing success.manga_viewer"): + verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_ids() -> None: + """Verify manga-viewer payload verifier requires non-zero identity fields.""" + parsed = Response() + parsed.success.manga_viewer.pages.add().manga_page.image_url = "http://img" + with pytest.raises(CaptureVerificationError, match="Missing viewer title_id/chapter_id fields"): + verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_image_urls() -> None: + """Verify manga-viewer payload verifier requires at least one image URL.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + parsed.success.manga_viewer.pages.add() + with pytest.raises(CaptureVerificationError, match="No manga_page.image_url found in pages"): + verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_accepts_declared_subscription_required() -> None: + """Verify subscription-required viewer captures can be stored as baseline records.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + + verify_manga_viewer_payload( + parsed, + "sample", + metadata={"expected_runtime_error": "subscription_required"}, + ) + + +def test_verify_manga_viewer_payload_rejects_missing_last_page_chapter() -> None: + """Verify manga-viewer payload verifier requires terminal chapter linkage.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + page = parsed.success.manga_viewer.pages.add() + page.manga_page.image_url = "http://img" + with pytest.raises(CaptureVerificationError, match="Missing last_page.current_chapter"): + verify_manga_viewer_payload(parsed, "sample") diff --git a/tests/test_capture_verify_signatures.py b/tests/test_capture_verify_signatures.py new file mode 100644 index 0000000..7e36a1b --- /dev/null +++ b/tests/test_capture_verify_signatures.py @@ -0,0 +1,204 @@ +"""Schema-signature helper tests for capture verification.""" + +from __future__ import annotations + +import json + +import pytest + +from mloader.infrastructure.mangaplus.api_response import ApiPayloadClassification +from mloader.infrastructure.mangaplus.capture_metadata import CaptureVerificationError +from mloader.infrastructure.mangaplus.capture_signatures import ( + as_dict, + as_list, + build_api_error_signature, + build_schema_signature, +) +from mloader.response_pb2 import Response + + +def test_build_api_error_signature_requires_error_details() -> None: + """Verify malformed internal classifications fail with a clear message.""" + with pytest.raises(CaptureVerificationError, match="Expected API error details"): + build_api_error_signature( + endpoint="title_index", + metadata={"params": {}}, + classification=ApiPayloadClassification(kind="api_error"), + ) + + +def test_build_schema_signature_rejects_unknown_endpoint() -> None: + """Verify schema-signature builder rejects unsupported endpoint names.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "title" + group = parsed.success.title_detail_view.chapter_list_group.add() + group.first_chapter_list.add().chapter_id = 1024959 + with pytest.raises(CaptureVerificationError, match="Unsupported endpoint"): + build_schema_signature( + endpoint="unknown", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=parsed, + ) + + +def test_as_dict_rejects_non_dict() -> None: + """Verify dict coercion helper rejects non-object values.""" + with pytest.raises(CaptureVerificationError, match="Expected object at"): + as_dict([], "ctx") + + +def test_as_list_rejects_non_list() -> None: + """Verify list coercion helper rejects non-list values.""" + with pytest.raises(CaptureVerificationError, match="Expected list at"): + as_list({}, "ctx") + + +def test_build_schema_signature_rejects_empty_pages_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects manga_viewer payloads with explicit empty pages list.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: {"success": {"manga_viewer": {"pages": []}}}, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one page"): + build_schema_signature( + endpoint="manga_viewer", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + + +def test_build_schema_signature_accepts_subscription_required_manga_viewer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signatures can describe subscription-required viewer payloads.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: {"success": {"manga_viewer": {"pages": []}}}, + ) + + signature = json.loads( + build_schema_signature( + endpoint="manga_viewer", + metadata={ + "expected_runtime_error": "subscription_required", + "params": {}, + "url": "https://example.invalid/api/manga_viewer", + }, + parsed=Response(), + ) + ) + + assert signature["payload_state"] == "subscription_required" + + +def test_build_schema_signature_rejects_empty_title_index_groups( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title-index payloads with no groups.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: {"success": {"all_titles_view": {"title_groups": []}}}, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one group"): + build_schema_signature( + endpoint="title_index", + metadata={"params": {}, "url": "https://example.invalid/api/title_list/allV2"}, + parsed=Response(), + ) + + +def test_build_schema_signature_rejects_title_index_groups_without_titles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title-index groups with no titles.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: { + "success": {"all_titles_view": {"title_groups": [{"titles": []}]}} + }, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one title"): + build_schema_signature( + endpoint="title_index", + metadata={"params": {}, "url": "https://example.invalid/api/title_list/allV2"}, + parsed=Response(), + ) + + +def test_build_schema_signature_rejects_empty_title_detail_chapter_lists( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title_detail payloads with no chapters.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: { + "success": {"title_detail_view": {"title": {}, "chapter_list_group": []}} + }, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one chapter group"): + build_schema_signature( + endpoint="title_detailV3", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + + +def test_build_schema_signature_rejects_empty_first_chapter_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title_detail payloads with empty first_chapter_list.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: { + "success": { + "title_detail_view": { + "title": {}, + "chapter_list_group": [{"first_chapter_list": []}], + } + } + }, + ) + + with pytest.raises( + CaptureVerificationError, match="Expected at least one chapter in first_chapter_list" + ): + build_schema_signature( + endpoint="title_detailV3", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + + +def test_build_schema_signature_accepts_flat_title_detail_chapter_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature accepts mobile title_detail flat chapter lists.""" + monkeypatch.setattr( + "mloader.infrastructure.mangaplus.capture_signatures.MessageToDict", + lambda *_args, **_kwargs: { + "success": { + "title_detail_view": { + "title": {}, + "chapter_list": [{"chapter_id": 1024959, "name": "#001"}], + } + } + }, + ) + + signature = json.loads( + build_schema_signature( + endpoint="title_detailV3", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + ) + + assert signature["chapter_source"] == "chapter_list" + assert signature["chapter_keys"] == ["chapter_id", "name"] diff --git a/tests/test_cli_capture_mode.py b/tests/test_cli_capture_mode.py new file mode 100644 index 0000000..2e85d4a --- /dev/null +++ b/tests/test_cli_capture_mode.py @@ -0,0 +1,133 @@ +"""Tests for CLI capture-verification mode.""" + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from mloader.cli import command_defaults as cli_defaults +from mloader.cli import main as cli_main +from mloader.cli.exit_codes import VALIDATION_ERROR +from mloader.infrastructure.mangaplus.capture_verify import ( + CaptureVerificationError, + CaptureVerificationSummary, +) + +CHAPTER_ID = "1024959" + + +def test_cli_verifies_capture_schema_and_exits_without_download( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema-verification mode runs and exits without invoking downloads.""" + monkeypatch.setattr( + cli_defaults, + "verify_capture_schema", + lambda _path: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == 0 + assert "Verified 3 capture payload(s) in tests/fixtures/api_captures/baseline" in result.output + + +def test_cli_verifies_capture_schema_against_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify baseline comparison mode calls baseline verification path.""" + monkeypatch.setattr( + cli_defaults, + "verify_capture_schema_against_baseline", + lambda _capture, _baseline: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + [ + "--verify-capture-schema", + "tests/fixtures/api_captures/baseline", + "--verify-capture-baseline", + "tests/fixtures/api_captures/baseline", + ], + ) + + assert result.exit_code == 0 + assert "against baseline tests/fixtures/api_captures/baseline" in result.output + + +def test_cli_rejects_baseline_option_without_capture_schema() -> None: + """Verify baseline option requires a capture directory option.""" + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-baseline", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == VALIDATION_ERROR + assert "--verify-capture-baseline requires --verify-capture-schema." in result.output + + +def test_cli_verify_capture_schema_returns_click_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify verification failures are exposed as click exceptions.""" + + def _raise_error(_path: str) -> None: + raise CaptureVerificationError("schema drift") + + monkeypatch.setattr(cli_defaults, "verify_capture_schema", _raise_error) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == VALIDATION_ERROR + assert "schema drift" in result.output + + +def test_cli_verify_capture_schema_json_mode_returns_structured_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json emits machine-readable payload for capture verification mode.""" + monkeypatch.setattr( + cli_defaults, + "verify_capture_schema", + lambda _path: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--json", "--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == { + "status": "ok", + "mode": "verify_capture", + "exit_code": 0, + "capture_dir": "tests/fixtures/api_captures/baseline", + "baseline_dir": None, + "total_records": 3, + "endpoint_counts": {"manga_viewer": 2, "title_detailV3": 1}, + } diff --git a/tests/test_cli_command_requests.py b/tests/test_cli_command_requests.py new file mode 100644 index 0000000..e98ed5c --- /dev/null +++ b/tests/test_cli_command_requests.py @@ -0,0 +1,96 @@ +"""Tests for translating CLI options into application request models.""" + +from __future__ import annotations + +import click +from click.testing import CliRunner + +from mloader.cli import command_requests +from mloader.domain.requests import DownloadRequest + + +def _invoke_request_builder(args: list[str]) -> DownloadRequest: + """Run a tiny Click command and return the constructed download request.""" + captured: dict[str, DownloadRequest] = {} + + @click.command() + @click.option("--cover", is_flag=True, default=False) + @click.option("--cover-format", default="png") + @click.pass_context + def command(ctx: click.Context, cover: bool, cover_format: str) -> None: + request = command_requests.build_download_request( + ctx, + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=cover, + cover_format=cover_format, + resume=True, + manifest_reset=False, + chapters=None, + chapter_ids={1024959}, + titles=None, + run_report_path=None, + ) + captured["request"] = request + + result = CliRunner().invoke(command, args) + assert result.exit_code == 0 + return captured["request"] + + +def test_build_download_request_preserves_default_cover_disabled() -> None: + """Verify default CLI cover options keep cover export disabled.""" + request = _invoke_request_builder([]) + + assert request.cover is False + assert request.cover_format == "png" + + +def test_build_download_request_cover_format_implies_cover() -> None: + """Verify explicit cover format enables cover export.""" + request = _invoke_request_builder(["--cover-format", "webp"]) + + assert request.cover is True + assert request.cover_format == "webp" + + +def test_validate_discovery_flags_delegates_application_validation() -> None: + """Verify CLI discovery flag validation returns stable user-facing messages.""" + assert ( + command_requests.validate_discovery_flags( + download_all_titles=False, + list_only=True, + languages=(), + ) + == "--list-only requires --all." + ) + + +def test_build_discovery_request_carries_capture_api_dir() -> None: + """Verify discovery request construction keeps capture settings from the download request.""" + download_request = _invoke_request_builder([]) + discovery_request = command_requests.build_discovery_request( + request=download_request, + pages=("https://example.com/list",), + title_index_endpoint="https://example.com/allV2", + id_length=6, + languages=("english",), + browser_fallback=True, + ) + + assert discovery_request.pages == ("https://example.com/list",) + assert discovery_request.title_index_endpoint == "https://example.com/allV2" + assert discovery_request.id_length == 6 + assert discovery_request.languages == ("english",) + assert discovery_request.browser_fallback is True + assert discovery_request.capture_api_dir is None diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..2db1e86 --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,49 @@ +"""Tests for logging configuration helpers.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from mloader.cli import config as cli_config + + +def test_setup_logging_calls_basic_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure setup_logging configures handlers and logger levels.""" + captured: dict[str, Any] = {} + + def fake_basic_config(**kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(logging, "basicConfig", fake_basic_config) + + cli_config.setup_logging() + + assert captured["level"] == logging.INFO + assert captured["style"] == "{" + assert captured["force"] is True + assert isinstance(captured["handlers"][0], logging.StreamHandler) + assert logging.getLogger("requests").level == logging.WARNING + assert logging.getLogger("urllib3").level == logging.WARNING + + +def test_setup_logging_accepts_explicit_level(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure setup_logging forwards custom logging level overrides.""" + captured: dict[str, Any] = {} + + def fake_basic_config(**kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(logging, "basicConfig", fake_basic_config) + + cli_config.setup_logging(level=logging.DEBUG) + + assert captured["level"] == logging.DEBUG + + +def test_get_logger_returns_named_logger() -> None: + """Ensure get_logger returns a logger configured with the requested name.""" + logger = cli_config.get_logger("mloader.tests") + assert logger.name == "mloader.tests" diff --git a/tests/test_cli_download_mode.py b/tests/test_cli_download_mode.py new file mode 100644 index 0000000..e608fe9 --- /dev/null +++ b/tests/test_cli_download_mode.py @@ -0,0 +1,191 @@ +"""Tests for CLI download-mode behavior.""" + +from __future__ import annotations + +from typing import cast + +import pytest +from click.testing import CliRunner + +from mloader.cli import main as cli_main +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG +from mloader.types import ExporterFactoryLike +from tests.cli_fakes import ( + FakeChapter, + FakeTitle, + RecordingDownloadRuntime, + RecordingPdfExporter, + RecordingRawExporter, + RuntimeFailingDownloadRuntime, + SubscriptionRequiredDownloadRuntime, +) + +CHAPTER_ID = "1024959" + + +def test_cli_uses_raw_exporter_when_raw_flag_is_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --raw selects RawExporter and forwards chapter IDs.""" + RecordingRawExporter.init_args = None + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + monkeypatch.setattr(cli_main, "RawExporter", RecordingRawExporter) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--raw"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.download_args is not None + exporter_factory = cast( + ExporterFactoryLike, RecordingDownloadRuntime.init_args["exporter_factory"] + ) + exporter = exporter_factory(title=FakeTitle(), chapter=FakeChapter()) + assert isinstance(exporter, RecordingRawExporter) + assert RecordingRawExporter.init_args is not None + assert RecordingRawExporter.init_args["destination"] == "mloader_downloads" + assert RecordingDownloadRuntime.init_args["output_format"] == "raw" + assert RecordingDownloadRuntime.download_args["chapter_ids"] == {int(CHAPTER_ID)} + + +def test_cli_uses_pdf_exporter_when_requested(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --format pdf selects PDFExporter and forwards chapter IDs.""" + RecordingPdfExporter.init_args = None + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + monkeypatch.setattr(cli_main, "PDFExporter", RecordingPdfExporter) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--format", "pdf"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.download_args is not None + exporter_factory = cast( + ExporterFactoryLike, RecordingDownloadRuntime.init_args["exporter_factory"] + ) + exporter = exporter_factory(title=FakeTitle(), chapter=FakeChapter()) + assert isinstance(exporter, RecordingPdfExporter) + assert RecordingPdfExporter.init_args is not None + assert RecordingPdfExporter.init_args["destination"] == "mloader_downloads" + assert RecordingDownloadRuntime.init_args["output_format"] == "pdf" + assert RecordingDownloadRuntime.download_args["chapter_ids"] == {int(CHAPTER_ID)} + + +def test_cli_returns_error_when_download_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI returns a click error when loader download raises.""" + monkeypatch.setattr(cli_main, "MangaLoader", RuntimeFailingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == INTERNAL_BUG + assert "Download failed" in result.output + + +def test_cli_returns_subscription_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI exposes subscription requirement failures from downloader.""" + monkeypatch.setattr(cli_main, "MangaLoader", SubscriptionRequiredDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "A MAX subscription is required to download this chapter." in result.output + + +def test_cli_forwards_capture_directory(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --capture-api forwards directory to loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, ["--chapter-id", CHAPTER_ID, "--capture-api", "/tmp/captures"] + ) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["capture_api_dir"] == "/tmp/captures" + + +def test_cli_forwards_cover_flag(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --cover enables title-cover download mode in loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--cover"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["cover"] is True + assert RecordingDownloadRuntime.init_args["cover_format"] == "png" + + +def test_cli_forwards_cover_format_when_cover_is_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --cover-format selects title-cover image format.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--chapter-id", CHAPTER_ID, "--cover", "--cover-format", "webp"], + ) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["cover"] is True + assert RecordingDownloadRuntime.init_args["cover_format"] == "webp" + + +def test_cli_cover_format_implies_cover(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify explicit --cover-format enables title-cover download mode.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--cover-format", "jpg"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["cover"] is True + assert RecordingDownloadRuntime.init_args["cover_format"] == "jpg" + + +def test_cli_rejects_invalid_cover_format() -> None: + """Verify unsupported cover formats fail during CLI validation.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--cover-format", "bmp"]) + + assert result.exit_code == 2 + assert "Invalid value for '--cover-format'" in result.output + + +def test_cli_cover_defaults_to_disabled_with_png_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cover download remains disabled by default.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["cover"] is False + assert RecordingDownloadRuntime.init_args["cover_format"] == "png" + + +def test_cli_forwards_resume_and_manifest_reset_options(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify manifest behavior flags are forwarded to loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--chapter-id", CHAPTER_ID, "--no-resume", "--manifest-reset"], + ) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["resume"] is False + assert RecordingDownloadRuntime.init_args["manifest_reset"] is True diff --git a/tests/test_cli_examples.py b/tests/test_cli_examples.py new file mode 100644 index 0000000..1d5b841 --- /dev/null +++ b/tests/test_cli_examples.py @@ -0,0 +1,44 @@ +"""Tests for CLI example catalog generation and option coverage.""" + +from __future__ import annotations + +import re + +import click + +from mloader.cli.examples import build_cli_examples +from mloader.cli.main import main as cli_main + + +def _extract_option_names_from_help(help_text: str) -> set[str]: + """Extract long option names from Click help output.""" + option_names: set[str] = set() + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + matches = re.findall(r"--[a-zA-Z0-9-]+", stripped) + option_names.update(matches) + return option_names + + +def test_cli_examples_cover_all_long_options() -> None: + """Verify example catalog includes at least one command using every long option.""" + context = click.Context(cli_main) + help_text = cli_main.get_help(context) + required_options = _extract_option_names_from_help(help_text) - {"--help"} + + covered_options: set[str] = set() + for example in build_cli_examples(prog_name="mloader"): + covered_options.update(re.findall(r"--[a-zA-Z0-9-]+", example.command)) + + missing = sorted(required_options - covered_options) + assert not missing, f"Examples are missing coverage for: {', '.join(missing)}" + + +def test_cli_examples_render_with_custom_program_name() -> None: + """Verify example command strings are rendered with provided program name.""" + examples = build_cli_examples(prog_name="my-loader") + + assert examples + assert all(example.command.startswith("my-loader ") for example in examples) diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 0000000..71a5b98 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,144 @@ +"""Tests for core CLI command orchestration.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from click.testing import CliRunner + +from mloader.cli import download_command as cli_download_command +from mloader.cli import main as cli_main +from mloader.cli.exit_codes import VALIDATION_ERROR +from mloader.config import AuthSettings +from tests.cli_fakes import RecordingDownloadRuntime + +CHAPTER_ID = "1024959" + + +def test_cli_uses_default_info_logging_level(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI configures INFO logging in default output mode.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == 0 + assert observed_level == 20 + + +def test_cli_exits_when_auth_os_value_is_unsupported(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI warns and exits when auth OS config value is unsupported.""" + monkeypatch.setattr( + cli_main, + "AUTH_SETTINGS", + AuthSettings( + app_ver="97", + os="Windows_NT", + os_ver="18.1", + secret="secret", + ), + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == VALIDATION_ERROR + assert "Unsupported API auth OS value" in result.output + assert "Windows_NT" in result.output + + +def test_cli_uses_warning_logging_level_in_quiet_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --quiet configures WARNING logging and suppresses intro text.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--quiet"]) + + assert result.exit_code == 0 + assert observed_level == 30 + assert cli_main.about.__intro__ not in result.output + + +def test_cli_uses_debug_logging_level_in_verbose_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --verbose enables DEBUG logging level.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--verbose"]) + + assert result.exit_code == 0 + assert observed_level == 10 + + +def test_cli_without_ids_prints_help_and_exits_cleanly() -> None: + """Verify CLI prints usage text when no chapter/title input is provided.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, []) + + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "Examples:" not in result.output + + +def test_cli_show_examples_exits_without_download(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --show-examples prints catalog and exits before download workflow.""" + invoked = {"download_called": False} + + def _raise_if_called(*args: Any, **kwargs: Any) -> None: + del args, kwargs + invoked["download_called"] = True + raise AssertionError("execute_download should not be called in --show-examples mode") + + monkeypatch.setattr( + cli_download_command.download_use_cases, + "execute_download", + _raise_if_called, + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--show-examples"]) + + assert result.exit_code == 0 + assert "mloader example catalog" in result.output + assert "--manifest-reset" in result.output + assert invoked["download_called"] is False + + +def test_cli_show_examples_json_mode_returns_catalog() -> None: + """Verify --show-examples with --json emits structured example payload.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--show-examples", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["mode"] == "show_examples" + assert payload["count"] > 0 + assert isinstance(payload["examples"], list) diff --git a/tests/test_cli_presenter.py b/tests/test_cli_presenter.py new file mode 100644 index 0000000..a56cfdf --- /dev/null +++ b/tests/test_cli_presenter.py @@ -0,0 +1,143 @@ +"""Tests for CLI output presenter behavior.""" + +from __future__ import annotations + +import json + +import pytest + +from mloader.cli.examples import CliExample +from mloader.cli.presenter import CliPresenter +from mloader.domain.requests import DownloadSummary +from mloader.infrastructure.mangaplus.capture_verify import CaptureVerificationSummary + + +def test_presenter_emits_human_intro_when_enabled(capsys: pytest.CaptureFixture[str]) -> None: + """Verify intro banner is emitted in default human-output mode.""" + presenter = CliPresenter(json_output=False, quiet=False) + + presenter.emit_intro("hello") + + assert "hello" in capsys.readouterr().out + + +def test_presenter_suppresses_human_intro_in_quiet_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify intro banner is suppressed in quiet mode.""" + presenter = CliPresenter(json_output=False, quiet=True) + + presenter.emit_intro("hello") + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_capture_summary_json_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify capture summary is emitted as structured JSON in json mode.""" + presenter = CliPresenter(json_output=True, quiet=False) + + presenter.emit_capture_verification( + summary=CaptureVerificationSummary(total_records=3, endpoint_counts={"a": 1, "b": 2}), + capture_dir="./capture", + baseline_dir=None, + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "ok" + assert payload["mode"] == "verify_capture" + assert payload["total_records"] == 3 + assert payload["endpoint_counts"] == {"a": 1, "b": 2} + + +def test_presenter_suppresses_capture_summary_in_quiet_human_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify quiet human mode suppresses capture-summary output.""" + presenter = CliPresenter(json_output=False, quiet=True) + + presenter.emit_capture_verification( + summary=CaptureVerificationSummary(total_records=1, endpoint_counts={"manga_viewer": 1}), + capture_dir="./capture", + baseline_dir="./baseline", + ) + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_download_summary_human_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify human mode prints a compact download summary with failed IDs.""" + presenter = CliPresenter(json_output=False, quiet=False) + presenter.emit_download_summary( + DownloadSummary( + downloaded=3, + skipped_manifest=2, + failed=1, + failed_chapter_ids=(99,), + ) + ) + + output = capsys.readouterr().out + assert "downloaded=3" in output + assert "skipped_manifest=2" in output + assert "failed=1" in output + assert "99" in output + + +def test_presenter_suppresses_download_summary_in_quiet_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify quiet mode suppresses download-summary output.""" + presenter = CliPresenter(json_output=False, quiet=True) + presenter.emit_download_summary( + DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + ) + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_examples_human_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Verify presenter prints example catalog in human mode.""" + presenter = CliPresenter(json_output=False, quiet=False) + presenter.emit_examples( + [ + CliExample( + title="Example title", + command="mloader --chapter-id 1024959", + description="Example description.", + ) + ] + ) + + output = capsys.readouterr().out + assert "mloader example catalog" in output + assert "Example title" in output + assert "mloader --chapter-id 1024959" in output + + +def test_presenter_emits_examples_json_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Verify presenter emits structured example catalog in JSON mode.""" + presenter = CliPresenter(json_output=True, quiet=False) + presenter.emit_examples( + [ + CliExample( + title="Example title", + command="mloader --chapter-id 1024959", + description="Example description.", + ) + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "ok" + assert payload["mode"] == "show_examples" + assert payload["count"] == 1 + assert payload["examples"][0]["command"] == "mloader --chapter-id 1024959" diff --git a/tests/test_cli_readme_reference.py b/tests/test_cli_readme_reference.py new file mode 100644 index 0000000..6393b18 --- /dev/null +++ b/tests/test_cli_readme_reference.py @@ -0,0 +1,85 @@ +"""Tests for README CLI reference generation helpers.""" + +from __future__ import annotations + +import click +import pytest + +from mloader.cli import readme_reference + + +def test_format_default_variants() -> None: + """Verify default rendering handles hidden, None, tuple, bool, and scalar values.""" + hidden_default = click.Option(["--hidden"], default="x", show_default=False) + none_default = click.Option(["--none"], default=None, show_default=True) + tuple_default = click.Option(["--multi"], default=("a", "b"), show_default=True) + bool_default = click.Option(["--flag"], is_flag=True, default=True, show_default=True) + scalar_default = click.Option(["--name"], default="abc", show_default=True) + + assert readme_reference._format_default(hidden_default) == "-" + assert readme_reference._format_default(none_default) == "-" + assert readme_reference._format_default(tuple_default) == "a, b" + assert readme_reference._format_default(bool_default) == "true" + assert readme_reference._format_default(scalar_default) == "abc" + + +def test_format_envvar_variants() -> None: + """Verify envvar rendering supports absent, tuple, and scalar envvar values.""" + no_envvar = click.Option(["--no-env"]) + tuple_envvar = click.Option(["--tuple-env"], envvar=("A", "B")) + scalar_envvar = click.Option(["--scalar-env"], envvar="A") + + assert readme_reference._format_envvar(no_envvar) == "-" + assert readme_reference._format_envvar(tuple_envvar) == "A, B" + assert readme_reference._format_envvar(scalar_envvar) == "A" + + +def test_render_cli_parameter_reference_escapes_markdown_cells() -> None: + """Verify rendered table escapes pipes and normalizes multiline help text.""" + command = click.Command( + "demo", + params=[ + click.Option( + ["--option"], + help="line one\nline two | with pipe", + default="value", + show_default=True, + ), + ], + ) + + rendered = readme_reference.render_cli_parameter_reference(command) + + assert "`URLS`:" in rendered + assert "line one line two \\| with pipe" in rendered + assert "| `--option` |" in rendered + + +def test_replace_readme_cli_reference_replaces_marker_section() -> None: + """Verify README replacement updates only the marker-delimited section.""" + template = ( + "prefix\n" + f"{readme_reference.README_CLI_REFERENCE_START}\n" + "old text\n" + f"{readme_reference.README_CLI_REFERENCE_END}\n" + "suffix\n" + ) + command = click.Command( + "demo", + params=[click.Option(["--foo"], help="help", default="bar", show_default=True)], + ) + + updated = readme_reference.replace_readme_cli_reference(template, command=command) + + assert "prefix" in updated + assert "suffix" in updated + assert "old text" not in updated + assert "| `--foo` | help | `bar` | `-` |" in updated + + +def test_replace_readme_cli_reference_raises_without_markers() -> None: + """Verify replacement raises when README marker comments are missing.""" + command = click.Command("demo") + + with pytest.raises(ValueError, match="README CLI reference markers not found"): + readme_reference.replace_readme_cli_reference("no markers here", command=command) diff --git a/tests/test_cli_report_mode.py b/tests/test_cli_report_mode.py new file mode 100644 index 0000000..5d76ccd --- /dev/null +++ b/tests/test_cli_report_mode.py @@ -0,0 +1,328 @@ +"""Tests for CLI JSON, run-report, and error-report behavior.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from mloader.application import requests as app_requests +from mloader.cli import main as cli_main +from mloader.cli.download_command import run_download_request +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG +from mloader.cli.presenter import CliPresenter +from mloader.cli.run_report import write_run_report_if_requested +from mloader.domain.requests import DownloadSummary +from tests.cli_fakes import ( + DEFAULT_FAILED_CHAPTER_IDS, + DEFAULT_INTERRUPTED_CHAPTER_ID, + InterruptedDownloadRuntime, + PartialFailureDownloadRuntime, + RecordingDownloadRuntime, + RecordingPdfExporter, + RecordingRawExporter, + RequestErrorDownloadRuntime, + RuntimeFailingDownloadRuntime, +) + +CHAPTER_ID = "1024959" + + +def test_cli_json_mode_returns_structured_success_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json returns machine-readable success payload for downloads.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["mode"] == "download" + assert payload["exit_code"] == 0 + assert payload["targets"]["chapters"] == 0 + assert payload["targets"]["chapter_ids"] == 1 + assert payload["summary"] == { + "downloaded": 1, + "skipped_manifest": 0, + "failed": 0, + "failed_chapter_ids": [], + } + + +def test_cli_writes_run_report_when_requested( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify optional run reports capture cron-friendly run metadata.""" + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + report_path = tmp_path / "run-report.json" + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--chapter-id", CHAPTER_ID, "--run-report", str(report_path)], + ) + + assert result.exit_code == 0 + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["status"] == "ok" + assert report["exit_code"] == 0 + assert report["selected_args"]["target_chapter_ids"] == 1 + assert report["selected_args"]["cover"] is False + assert report["selected_args"]["cover_format"] == "png" + assert report["summary"]["downloaded"] == 1 + assert report["subscription_access_failures"] == 0 + assert report["exporter_safety"]["version"] == "pdf-streaming-and-atomic-cbz-v1" + + +def test_cli_writes_error_run_report_when_download_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify failed runs include error text in optional run reports.""" + monkeypatch.setattr(cli_main, "MangaLoader", RuntimeFailingDownloadRuntime) + report_path = tmp_path / "run-report.json" + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--chapter-id", CHAPTER_ID, "--run-report", str(report_path)], + ) + + assert result.exit_code == INTERNAL_BUG + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["status"] == "error" + assert report["exit_code"] == INTERNAL_BUG + assert "Download failed: boom" == report["error"] + + +def test_run_report_write_errors_are_logged( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify report write failures do not mask the original CLI outcome.""" + request = app_requests.build_download_request( + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=None, + chapter_ids={int(CHAPTER_ID)}, + titles=None, + run_report_path="/tmp/report.json", + ) + + def _raise_write_error(self: Path, *_args: object, **_kwargs: object) -> int: + del self + raise OSError("disk full") + + monkeypatch.setattr(Path, "write_text", _raise_write_error) + caplog.set_level(logging.WARNING) + + write_run_report_if_requested( + request, + run_id="run-1", + started_at=datetime.now(timezone.utc), + status="ok", + exit_code=0, + discovery=None, + summary=DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ), + error_message=None, + ) + + assert "Failed to write run report" in caplog.text + + +def test_run_download_request_injects_report_run_id_and_clock() -> None: + """Verify download command reporting does not depend on global time/id state.""" + request = app_requests.build_download_request( + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=None, + chapter_ids={int(CHAPTER_ID)}, + titles=None, + run_report_path="/tmp/report.json", + ) + started_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + completed_at = datetime(2026, 1, 1, 0, 0, 2, tzinfo=timezone.utc) + timestamps = iter((started_at, completed_at)) + reports: list[dict[str, object]] = [] + + def _write_report(_request: object, **kwargs: object) -> None: + reports.append(kwargs) + + run_download_request( + request, + presenter=CliPresenter(json_output=False, quiet=True), + discovery_metadata=None, + loader_factory=RecordingDownloadRuntime, + raw_exporter=RecordingRawExporter, + pdf_exporter=RecordingPdfExporter, + cbz_exporter=RecordingRawExporter, + write_run_report=_write_report, + run_id_factory=lambda: "fixed-run-id", + clock=lambda: next(timestamps), + ) + + assert reports == [ + { + "run_id": "fixed-run-id", + "started_at": started_at, + "completed_at": completed_at, + "status": "ok", + "exit_code": 0, + "discovery": None, + "summary": DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ), + "error_message": None, + } + ] + + +def test_cli_json_mode_returns_structured_error_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json returns machine-readable error payload and deterministic exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", RuntimeFailingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == INTERNAL_BUG + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": INTERNAL_BUG, + "message": "Download failed", + } + + +def test_cli_maps_request_failures_to_external_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify request-layer failures are mapped to external-failure exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", RequestErrorDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download request failed: network down" in result.output + + +def test_cli_maps_interrupted_download_to_external_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupted runs include partial summary and map to external failure.""" + monkeypatch.setattr(cli_main, "MangaLoader", InterruptedDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download summary: downloaded=1, skipped_manifest=1, failed=1" in result.output + assert f"Failed chapter IDs: {DEFAULT_INTERRUPTED_CHAPTER_ID}" in result.output + assert "Download interrupted by user." in result.output + + +def test_cli_returns_external_failure_when_summary_has_failed_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify failed chapter summary maps to external failure exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", PartialFailureDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download completed with 2 failed chapter(s)." in result.output + + +def test_cli_json_mode_includes_failed_summary_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify JSON error payload includes summary details for partial failures.""" + monkeypatch.setattr(cli_main, "MangaLoader", PartialFailureDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": EXTERNAL_FAILURE, + "message": "Download completed with 2 failed chapter(s).", + "summary": { + "downloaded": 2, + "skipped_manifest": 1, + "failed": 2, + "failed_chapter_ids": [DEFAULT_FAILED_CHAPTER_IDS[0], DEFAULT_FAILED_CHAPTER_IDS[1]], + }, + } + + +def test_cli_json_mode_includes_interrupted_summary_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupted runs emit JSON error payload including partial summary.""" + monkeypatch.setattr(cli_main, "MangaLoader", InterruptedDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": EXTERNAL_FAILURE, + "message": "Download interrupted by user.", + "summary": { + "downloaded": 1, + "skipped_manifest": 1, + "failed": 1, + "failed_chapter_ids": [int(DEFAULT_INTERRUPTED_CHAPTER_ID)], + }, + } diff --git a/tests/test_cli_title_discovery.py b/tests/test_cli_title_discovery.py new file mode 100644 index 0000000..20ad766 --- /dev/null +++ b/tests/test_cli_title_discovery.py @@ -0,0 +1,442 @@ +"""Tests for ``mloader --all`` CLI orchestration.""" + +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest +import requests +from click.testing import CliRunner + +from mloader.cli.exit_codes import EXTERNAL_FAILURE, VALIDATION_ERROR +from mloader.cli import main as cli_main +from mloader.errors import APIResponseError +from tests.cli_fakes import ( + RecordingDownloadRuntime, + RuntimeFailingDownloadRuntime, + ShortSubscriptionRequiredDownloadRuntime, + SinglePartialFailureDownloadRuntime, +) + + +def _patch_discovery_gateway( + monkeypatch: pytest.MonkeyPatch, + *, + api: Any | None = None, + static: Any | None = None, + browser: Any | None = None, +) -> None: + """Patch production discovery gateway methods for CLI orchestration tests.""" + gateway = cli_main.title_discovery.DEFAULT_GATEWAY + if api is not None: + monkeypatch.setattr(gateway, "collect_title_ids_from_api", api) + if static is not None: + monkeypatch.setattr(gateway, "collect_title_ids", static) + if browser is not None: + monkeypatch.setattr(gateway, "collect_title_ids_with_browser", browser) + + +def test_cli_list_only_prints_ids_without_downloading(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --all --list-only prints IDs and exits before loader initialization.""" + RecordingDownloadRuntime.init_args = None + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001, 100002], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--list-only"]) + + assert result.exit_code == 0 + assert "100001 100002" in result.output + assert RecordingDownloadRuntime.init_args is None + + +def test_cli_rejects_list_only_without_all() -> None: + """Verify --list-only requires --all mode.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--list-only"]) + + assert result.exit_code == VALIDATION_ERROR + assert "--list-only requires --all." in result.output + + +def test_cli_rejects_language_without_all() -> None: + """Verify --language requires --all mode.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--language", "english"]) + + assert result.exit_code == VALIDATION_ERROR + assert "--language requires --all." in result.output + + +def test_cli_forwards_language_filters_to_api(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --language is translated into language-code filters for API discovery.""" + observed_languages: set[int] | None = None + + def _collect( + *_args: object, + **kwargs: object, + ) -> list[int]: + nonlocal observed_languages + observed_languages = cast("set[int] | None", kwargs["allowed_languages"]) + return [100001] + + _patch_discovery_gateway(monkeypatch, api=_collect) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--all", "--language", "english", "--language", "spanish"], + ) + + assert result.exit_code == 0 + assert observed_languages == {0, 1} + + +def test_cli_downloads_with_loader_and_forwards_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --all initializes loader and forwards discovered title IDs.""" + RecordingDownloadRuntime.init_args = None + RecordingDownloadRuntime.download_args = None + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001, 100002], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--all", "--format", "cbz", "--capture-api", "/tmp/capture", "--out", "/tmp/downloads"], + ) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.download_args is not None + assert RecordingDownloadRuntime.init_args["output_format"] == "cbz" + assert RecordingDownloadRuntime.init_args["capture_api_dir"] == "/tmp/capture" + assert RecordingDownloadRuntime.download_args["title_ids"] == {100001, 100002} + + +def test_cli_download_uses_raw_exporter_branch(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --raw switches output format to raw.""" + RecordingDownloadRuntime.init_args = None + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--raw"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.init_args is not None + assert RecordingDownloadRuntime.init_args["output_format"] == "raw" + + +def test_cli_fails_when_no_titles_are_discovered(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI returns error when scraper finds no title IDs.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "No title IDs found on configured list pages." in result.output + + +def test_cli_fails_when_language_filter_has_no_results(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty API results with language filters raise a targeted message.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [100001], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--language", "german"]) + + assert result.exit_code != 0 + assert "No title IDs found for selected language filter(s): german." in result.output + + +def test_cli_fails_when_scraper_request_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify scraper request exceptions are surfaced as click errors.""" + + def _raise_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("network") + + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=_raise_error, + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "Failed to fetch title pages: network" in result.output + + +def test_cli_fails_when_language_filter_api_request_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify language filtering stops when API request fails.""" + + def _raise_api_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("api down") + + _patch_discovery_gateway(monkeypatch, api=_raise_api_error) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--language", "english"]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Language filtering requires API title-index access" in result.output + + +def test_cli_json_list_only_returns_structured_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json --all --list-only emits machine-readable title discovery output.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001, 100002], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--all", "--list-only"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == { + "status": "ok", + "mode": "all_list_only", + "exit_code": 0, + "count": 2, + "title_ids": [100001, 100002], + } + + +def test_cli_quiet_list_only_exits_without_human_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --quiet --all --list-only performs discovery and exits quietly.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001, 100002], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--quiet", "--all", "--list-only"]) + + assert result.exit_code == 0 + assert result.output == "" + + +def test_cli_fails_on_subscription_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify subscription failures propagate user-facing click message.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", ShortSubscriptionRequiredDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "subscription required" in result.output + + +def test_cli_all_mode_maps_partial_summary_failures_to_external_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --all returns external-failure exit code for partial chapter failures.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", SinglePartialFailureDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download completed with 1 failed chapter(s)." in result.output + + +def test_cli_fails_on_generic_loader_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify generic loader exceptions are wrapped as download failures.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RuntimeFailingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "Download failed" in result.output + + +def test_cli_uses_browser_fallback_when_static_scrape_returns_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify browser fallback is used when static extraction returns no IDs.""" + RecordingDownloadRuntime.download_args = None + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [], + browser=lambda *_args, **_kwargs: [100010, 100011], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert RecordingDownloadRuntime.download_args is not None + assert RecordingDownloadRuntime.download_args["title_ids"] == {100010, 100011} + + +def test_cli_can_disable_browser_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify disabling browser fallback keeps empty-result failure behavior.""" + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "No title IDs found on configured list pages." in result.output + + +def test_cli_fails_when_browser_fallback_raises_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify browser fallback runtime failures are surfaced as click errors.""" + + def _raise_runtime(*_args: object, **_kwargs: object) -> list[int]: + raise RuntimeError("browser missing") + + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [], + browser=_raise_runtime, + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "browser missing" in result.output + + +def test_cli_fails_when_browser_fallback_raises_generic(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify unexpected browser fallback errors are wrapped predictably.""" + + def _raise_generic(*_args: object, **_kwargs: object) -> list[int]: + raise ValueError("bad page") + + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=lambda *_args, **_kwargs: [], + browser=_raise_generic, + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "Browser fallback failed: bad page" in result.output + + +def test_cli_uses_browser_fallback_when_static_fetch_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify static fetch errors are reported before browser fallback succeeds.""" + + def _raise_static_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("static down") + + RecordingDownloadRuntime.download_args = None + _patch_discovery_gateway( + monkeypatch, + api=lambda *_args, **_kwargs: [], + static=_raise_static_error, + browser=lambda *_args, **_kwargs: [100070], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert "Static fetch failed: static down. Retrying with browser fallback." in result.output + assert RecordingDownloadRuntime.download_args is not None + assert RecordingDownloadRuntime.download_args["title_ids"] == {100070} + + +def test_cli_can_use_static_scrape_when_api_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify static scrape fallback can recover after API request failure.""" + + def _raise_api_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("api down") + + RecordingDownloadRuntime.download_args = None + _patch_discovery_gateway( + monkeypatch, + api=_raise_api_error, + static=lambda *_args, **_kwargs: [100050], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert "API title-index fetch failed: api down" in result.output + assert RecordingDownloadRuntime.download_args is not None + assert RecordingDownloadRuntime.download_args["title_ids"] == {100050} + + +def test_cli_can_use_static_scrape_when_api_payload_is_unusable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify static scrape fallback can recover after API payload/schema errors.""" + + def _raise_api_error(*_args: object, **_kwargs: object) -> list[int]: + raise APIResponseError("schema drift", kind="unknown") + + RecordingDownloadRuntime.download_args = None + _patch_discovery_gateway( + monkeypatch, + api=_raise_api_error, + static=lambda *_args, **_kwargs: [100051], + ) + monkeypatch.setattr(cli_main, "MangaLoader", RecordingDownloadRuntime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert "API title-index payload unusable: schema drift" in result.output + assert RecordingDownloadRuntime.download_args is not None + assert RecordingDownloadRuntime.download_args["title_ids"] == {100051} diff --git a/tests/test_config_module.py b/tests/test_config_module.py new file mode 100644 index 0000000..acf337b --- /dev/null +++ b/tests/test_config_module.py @@ -0,0 +1,150 @@ +"""Tests for environment-backed configuration values.""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + +import mloader.config as config + + +def test_module_auth_settings_respect_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure module auth settings reflect environment overrides after reload.""" + monkeypatch.setenv("APP_VER", "999") + monkeypatch.setenv("OS", "android") + monkeypatch.setenv("OS_VER", "42") + monkeypatch.setenv("SECRET", "secret-value") + + reloaded = importlib.reload(config) + + assert reloaded.AUTH_SETTINGS.as_query_params() == { + "app_ver": "999", + "os": "android", + "os_ver": "42", + "secret": "secret-value", + } + + +def test_load_auth_settings_uses_file_when_env_is_missing(tmp_path: Path) -> None: + """Ensure file-based auth config is used when no env overrides are present.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text( + '[auth]\napp_ver = "123"\nos = "android"\nos_ver = "14.0"\nsecret = "from-file"\n', + encoding="utf-8", + ) + + settings = config.load_auth_settings(environ={}, config_file=config_file) + + assert settings.as_query_params() == { + "app_ver": "123", + "os": "android", + "os_ver": "14.0", + "secret": "from-file", + } + + +def test_load_auth_settings_uses_env_over_file(tmp_path: Path) -> None: + """Ensure environment values override config-file values by key.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text( + '[auth]\napp_ver = "123"\nos = "android"\nos_ver = "14.0"\nsecret = "from-file"\n', + encoding="utf-8", + ) + + settings = config.load_auth_settings( + environ={ + "APP_VER": "999", + "OS": "ios", + "OS_VER": "18.1", + "SECRET": "from-env", + }, + config_file=config_file, + ) + + assert settings.as_query_params() == { + "app_ver": "999", + "os": "ios", + "os_ver": "18.1", + "secret": "from-env", + } + + +def test_load_auth_settings_uses_overrides_over_env_and_file(tmp_path: Path) -> None: + """Ensure explicit overrides are highest-priority values.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('[auth]\napp_ver = "111"\nos = "android"\n', encoding="utf-8") + + settings = config.load_auth_settings( + environ={"APP_VER": "222", "OS": "ios"}, + config_file=config_file, + overrides={"app_ver": "333"}, + ) + + assert settings.app_ver == "333" + assert settings.os == "ios" + + +def test_load_auth_settings_rejects_unknown_override_key() -> None: + """Ensure unknown override keys fail fast with explicit error message.""" + with pytest.raises(ValueError, match="Unsupported auth override key"): + config.load_auth_settings(overrides={"unknown": "value"}) + + +def test_load_auth_settings_ignores_missing_config_file_path() -> None: + """Ensure missing config file paths are treated as empty config data.""" + settings = config.load_auth_settings(environ={}, config_file="/tmp/does-not-exist-mloader.toml") + + assert settings.as_query_params() == { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "f40080bcb01a9a963912f46688d411a3", + } + + +def test_load_auth_settings_uses_env_config_file_path(tmp_path: Path) -> None: + """Ensure MLOADER_CONFIG_FILE path is used when explicit path is omitted.""" + config_file = tmp_path / "custom.toml" + config_file.write_text('[auth]\napp_ver = "777"\n', encoding="utf-8") + + settings = config.load_auth_settings( + environ={"MLOADER_CONFIG_FILE": str(config_file)}, + config_file=None, + ) + + assert settings.app_ver == "777" + + +def test_load_auth_settings_reads_default_config_file_from_cwd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure default .mloader.toml is discovered from current working directory.""" + default_config = tmp_path / ".mloader.toml" + default_config.write_text('[auth]\nsecret = "from-default"\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + settings = config.load_auth_settings(environ={}, config_file=None) + + assert settings.secret == "from-default" + + +def test_load_auth_settings_invalid_auth_table_type_raises(tmp_path: Path) -> None: + """Ensure invalid [auth] shape fails fast with explicit error details.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('auth = "invalid"', encoding="utf-8") + + with pytest.raises(ValueError, match="\\[auth\\] section must be a table"): + config.load_auth_settings(environ={}, config_file=config_file) + + +def test_load_auth_settings_missing_auth_section_uses_defaults(tmp_path: Path) -> None: + """Ensure config files without [auth] section do not override defaults.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('[other]\nvalue = "x"\n', encoding="utf-8") + + settings = config.load_auth_settings(environ={}, config_file=config_file) + + assert settings.as_query_params()["app_ver"] == "97" diff --git a/tests/test_decryption.py b/tests/test_decryption.py new file mode 100644 index 0000000..97d358a --- /dev/null +++ b/tests/test_decryption.py @@ -0,0 +1,26 @@ +"""Tests for decryption helper functions.""" + +from __future__ import annotations + +from mloader.manga_loader import decryption + + +def test_convert_hex_to_bytes_and_xor_decrypt_roundtrip() -> None: + """Verify XOR decryption restores the original plaintext bytes.""" + key = decryption._convert_hex_to_bytes("0f") + encrypted = bytearray([0x41 ^ 0x0F, 0x42 ^ 0x0F]) + + decrypted = decryption._xor_decrypt(encrypted, key) + + assert decrypted == bytearray(b"AB") + + +def test_xor_decrypt_accepts_repeating_key() -> None: + """Verify XOR decryption supports keys shorter than the payload.""" + key_hex = "0f0f" + original = bytearray(b"abc") + encrypted = decryption._xor_decrypt(bytearray(original), bytes.fromhex(key_hex)) + + decrypted = decryption._xor_decrypt(encrypted, decryption._convert_hex_to_bytes(key_hex)) + + assert decrypted == original diff --git a/tests/test_domain_manga.py b/tests/test_domain_manga.py new file mode 100644 index 0000000..2f875d8 --- /dev/null +++ b/tests/test_domain_manga.py @@ -0,0 +1,421 @@ +"""Tests for stable MangaPlus domain DTOs.""" + +from __future__ import annotations + +import pytest + +from mloader.domain.manga import Chapter, ChapterGroup, LastPage, MangaPage, MangaViewer, Title +from mloader.domain.manga import TitleDetail, ViewerPage +from mloader.domain.planning import ChapterSelection, DownloadPlan, TitleDownloadPlan +from mloader.domain.planning import build_download_plan + + +def _chapter(chapter_id: int, name: str) -> Chapter: + """Build a minimal chapter DTO for domain model tests.""" + return Chapter( + title_id=100010, + chapter_id=chapter_id, + name=name, + sub_title=f"Chapter {chapter_id}", + thumbnail_url="https://img.example/thumb.webp", + ) + + +def test_title_detail_chapters_flattens_all_groups_in_display_order() -> None: + """Verify title detail exposes all grouped chapters in MangaPlus order.""" + first = _chapter(1, "#001") + middle = _chapter(2, "#002") + last = _chapter(3, "#003") + detail = TitleDetail( + title=Title( + title_id=100010, + name="Dr. STONE", + author="Riichiro Inagaki / Boichi", + portrait_image_url="https://img.example/portrait.webp", + landscape_image_url="https://img.example/landscape.webp", + language=0, + ), + title_image_url="https://img.example/main.webp", + overview="Science adventure.", + non_appearance_info="", + number_of_views=123, + chapter_groups=( + ChapterGroup( + first_chapters=(first,), + mid_chapters=(middle,), + last_chapters=(last,), + ), + ), + ) + + assert detail.chapter_groups[0].chapters == (first, middle, last) + assert detail.chapters == (first, middle, last) + + +def test_manga_viewer_downloadable_pages_filters_non_image_pages() -> None: + """Verify viewer DTO exposes only page records with image URLs.""" + image_page = MangaPage( + image_url="https://img.example/page.jpg", + width=1200, + height=1800, + page_type=0, + encryption_key="", + ) + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(_chapter(1000311, "#001"),), + pages=( + ViewerPage(manga_page=image_page, last_page=None), + ViewerPage(manga_page=None, last_page=None), + ), + ) + + assert viewer.downloadable_pages == (image_page,) + + +def test_manga_viewer_last_page_returns_terminal_metadata() -> None: + """Verify viewer DTO exposes terminal metadata without probing page envelopes.""" + chapter = _chapter(1000311, "#001") + last_page = LastPage(current_chapter=chapter, next_chapter=None) + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(chapter,), + pages=( + ViewerPage(manga_page=None, last_page=None), + ViewerPage(manga_page=None, last_page=last_page), + ), + ) + + assert viewer.last_page is last_page + + +def test_manga_viewer_last_page_returns_none_without_terminal_metadata() -> None: + """Verify viewer DTO reports missing terminal metadata directly.""" + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(_chapter(1000311, "#001"),), + pages=(ViewerPage(manga_page=None, last_page=None),), + ) + + assert viewer.last_page is None + + +def test_download_plan_counts_concrete_title_plans() -> None: + """Verify concrete title plans expose stable selection summaries.""" + first_detail = TitleDetail( + title=Title( + title_id=100010, + name="One", + author="A", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=( + ChapterGroup( + first_chapters=(_chapter(1, "#001"), _chapter(2, "#002")), + mid_chapters=(), + last_chapters=(), + ), + ), + ) + second_detail = TitleDetail( + title=Title( + title_id=100020, + name="Two", + author="B", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=( + ChapterGroup(first_chapters=(_chapter(3, "#003"),), mid_chapters=(), last_chapters=()), + ), + ) + plan = DownloadPlan( + title_plans=( + TitleDownloadPlan(title_detail=first_detail, selected_chapters=first_detail.chapters), + TitleDownloadPlan(title_detail=second_detail, selected_chapters=second_detail.chapters), + ) + ) + + assert [selection.title_id for selection in plan.selections] == [100010, 100020] + assert plan.selections[0].chapter_ids == frozenset({1, 2}) + assert plan.title_count == 2 + assert plan.chapter_count == 3 + assert plan.selections[0].chapter_count == 2 + + +def test_chapter_selection_counts_selected_ids() -> None: + """Verify ID-only selection summaries expose chapter counts.""" + selection = ChapterSelection(title_id=100010, chapter_ids=frozenset({1, 2, 3})) + + assert selection.chapter_count == 3 + + +def test_build_download_plan_requires_at_least_one_target() -> None: + """Verify planning rejects empty target inputs.""" + with pytest.raises(ValueError, match="Expected at least one title or chapter id"): + build_download_plan( + title_ids=None, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda _title_id: _title_detail_with_chapters([]), + load_viewer=lambda _chapter_id: MangaViewer( + title_id=0, + chapter_id=0, + title_name="", + chapter_name="", + chapters=(), + pages=(), + ), + ) + + +def _title_detail_with_chapters(chapters: list[Chapter]) -> TitleDetail: + """Build a minimal title detail containing ``chapters``.""" + return TitleDetail( + title=Title( + title_id=100010, + name="Dr. STONE", + author="A", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=( + ChapterGroup(first_chapters=tuple(chapters), mid_chapters=(), last_chapters=()), + ), + ) + + +def test_build_download_plan_rejects_chapter_numbers_without_title_context() -> None: + """Verify chapter numbers require a title or direct viewer context.""" + with pytest.raises(ValueError, match="Chapter numbers require"): + build_download_plan( + title_ids=None, + chapter_numbers={1}, + chapter_ids=None, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda _title_id: _title_detail_with_chapters([]), + load_viewer=lambda _chapter_id: MangaViewer( + title_id=0, + chapter_id=0, + title_name="", + chapter_name="", + chapters=(), + pages=(), + ), + ) + + +def test_build_download_plan_last_chapter_selects_final_candidate() -> None: + """Verify last-chapter mode selects only the final candidate chapter.""" + first = _chapter(1000311, "#001") + last = _chapter(1000312, "#002") + detail = _title_detail_with_chapters([first, last]) + + plan = build_download_plan( + title_ids={100010}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=999, + last_chapter=True, + load_title_detail=lambda _title_id: detail, + load_viewer=lambda _chapter_id: MangaViewer( + title_id=0, + chapter_id=0, + title_name="", + chapter_name="", + chapters=(), + pages=(), + ), + ) + + assert plan.title_plans[0].selected_chapters == (last,) + + +def test_build_download_plan_uses_viewer_last_page_fallback_for_direct_chapter() -> None: + """Verify direct chapter IDs can be planned from viewer terminal metadata.""" + chapter = _chapter(1000311, "#001") + title_detail = TitleDetail( + title=Title( + title_id=100010, + name="Dr. STONE", + author="A", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=(ChapterGroup(first_chapters=(), mid_chapters=(), last_chapters=()),), + ) + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(), + pages=(ViewerPage(manga_page=None, last_page=LastPage(chapter, None)),), + ) + + plan = build_download_plan( + title_ids=None, + chapter_numbers=None, + chapter_ids={1000311}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda _title_id: title_detail, + load_viewer=lambda _chapter_id: viewer, + ) + + assert plan.title_plans[0].selected_chapters == (chapter,) + + +def test_build_download_plan_synthesizes_direct_chapter_without_viewer_chapters() -> None: + """Verify direct chapter planning has a minimal fallback when viewer metadata is sparse.""" + title_detail = TitleDetail( + title=Title( + title_id=100010, + name="Dr. STONE", + author="A", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=(ChapterGroup(first_chapters=(), mid_chapters=(), last_chapters=()),), + ) + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(), + pages=(), + ) + + plan = build_download_plan( + title_ids=None, + chapter_numbers=None, + chapter_ids={1000311}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda _title_id: title_detail, + load_viewer=lambda _chapter_id: viewer, + ) + + selected = plan.title_plans[0].selected_chapters[0] + assert selected.chapter_id == 1000311 + assert selected.name == "#001" + + +def test_build_download_plan_uses_matching_viewer_chapter_as_current_fallback() -> None: + """Verify direct chapter planning uses viewer chapter lists when terminal metadata is absent.""" + current = _chapter(1000311, "#001") + other = _chapter(1000312, "#002") + title_detail = _title_detail_with_chapters([]) + viewer = MangaViewer( + title_id=100010, + chapter_id=1000311, + title_name="Dr. STONE", + chapter_name="#001", + chapters=(other, current), + pages=(), + ) + + plan = build_download_plan( + title_ids=None, + chapter_numbers=None, + chapter_ids={1000311}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=lambda _title_id: title_detail, + load_viewer=lambda _chapter_id: viewer, + ) + + assert plan.title_plans[0].selected_chapters == (current,) + + +def test_build_download_plan_reuses_title_details_loaded_during_selection() -> None: + """Verify title detail payloads loaded for selection are not loaded again.""" + chapter = _chapter(1000311, "#001") + title_detail = TitleDetail( + title=Title( + title_id=100010, + name="Dr. STONE", + author="A", + portrait_image_url="", + landscape_image_url="", + language=0, + ), + title_image_url="", + overview="", + non_appearance_info="", + number_of_views=0, + chapter_groups=( + ChapterGroup(first_chapters=(chapter,), mid_chapters=(), last_chapters=()), + ), + ) + calls: list[int] = [] + + def load_title_detail(title_id: int) -> TitleDetail: + calls.append(title_id) + return title_detail + + plan = build_download_plan( + title_ids={100010}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=999, + last_chapter=False, + load_title_detail=load_title_detail, + load_viewer=lambda _chapter_id: MangaViewer( + title_id=0, + chapter_id=0, + title_name="", + chapter_name="", + chapters=(), + pages=(), + ), + ) + + assert calls == [100010] + assert plan.title_plans[0].selected_chapters == (chapter,) diff --git a/tests/test_domain_requests.py b/tests/test_domain_requests.py new file mode 100644 index 0000000..2fe3653 --- /dev/null +++ b/tests/test_domain_requests.py @@ -0,0 +1,83 @@ +"""Tests for immutable domain request models.""" + +from __future__ import annotations + +from mloader.domain.requests import DownloadRequest, MAX_CHAPTER_ID + + +def _build_request(*, end: int | None = None) -> DownloadRequest: + """Create a baseline download request for domain model tests.""" + return DownloadRequest( + out_dir="/tmp", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="super_high", + split=False, + begin=0, + end=end, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=frozenset(), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + +def test_download_request_max_chapter_defaults_to_domain_limit() -> None: + """Verify unset max bound resolves to the domain-level fallback limit.""" + request = _build_request(end=None) + + assert request.max_chapter == MAX_CHAPTER_ID + + +def test_download_request_max_chapter_uses_explicit_end_bound() -> None: + """Verify explicit chapter end bound is preserved as max chapter.""" + request = _build_request(end=42) + + assert request.max_chapter == 42 + + +def test_download_request_with_additional_titles_merges_and_deduplicates() -> None: + """Verify helper returns a new request with merged title IDs.""" + request = _build_request(end=42).with_additional_titles({100001, 100002}) + merged = request.with_additional_titles({100002, 100003}) + + assert request.titles == frozenset({100001, 100002}) + assert merged.titles == frozenset({100001, 100002, 100003}) + + +def test_download_request_has_targets_reflects_titles_or_chapters() -> None: + """Verify target presence flag is true when either target bucket is populated.""" + assert _build_request().has_targets is False + assert _build_request().with_additional_titles({1}).has_targets is True + + chapters_only = DownloadRequest( + out_dir="/tmp", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="super_high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + cover_format="png", + resume=True, + manifest_reset=False, + chapters=frozenset({77}), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + assert chapters_only.has_targets is True diff --git a/tests/test_download_execution.py b/tests/test_download_execution.py new file mode 100644 index 0000000..548ab25 --- /dev/null +++ b/tests/test_download_execution.py @@ -0,0 +1,237 @@ +"""Tests for concrete download execution behavior.""" + +from __future__ import annotations + +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from typing import Any + +import click +import pytest +from PIL import Image + +from mloader.constants import PageType +from mloader.domain.planning import DownloadPlan, TitleDownloadPlan +from mloader.domain.requests import DownloadSummary +from mloader.errors import DownloadInterruptedError +from mloader.manga_loader.chapter_planning import ChapterMetadata +from mloader.manga_loader.run_report import RunReport +from tests.downloader_helpers import ( + dummy_downloader, + DummyResponse, + full_downloader, + download_plan as _download_plan, + manga_page as _manga_page, + run_report as _run_report, + title_detail as _title_detail, + title_plan as _title_plan, +) + + +def test_download_calls_prepare_and_download(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify download() delegates to planning and plan execution.""" + calls: dict[str, Any] = {} + + def _prepare_download_plan(*args: Any) -> DownloadPlan: + """Capture prepare args and return a sentinel plan.""" + calls["prepare"] = args + return _download_plan(_title_plan(title_id=42, chapter_ids={1})) + + def _download(download_plan: DownloadPlan, report: RunReport) -> None: + """Capture the plan forwarded to _download.""" + del report + calls["download"] = download_plan + + loader = dummy_downloader() + monkeypatch.setattr(loader, "_prepare_download_plan", _prepare_download_plan) + monkeypatch.setattr(loader, "_download", _download) + summary = loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids={1024959}, + min_chapter=1, + max_chapter=5, + last_chapter=True, + ) + + assert calls["prepare"] == ({100312}, None, {1024959}, 1, 5, True) + assert calls["download"].selections[0].title_id == 42 + assert calls["download"].selections[0].chapter_ids == frozenset({1}) + assert summary == DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def test_download_clears_run_cache_before_and_after_execution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify download lifecycle clears run-level API cache at start and end.""" + calls: list[str] = [] + + def _prepare_download_plan(*args: Any) -> DownloadPlan: + """Return empty plan to keep flow deterministic.""" + del args + return DownloadPlan(title_plans=()) + + def _download(download_plan: DownloadPlan, report: RunReport) -> None: + """Record download invocation payload.""" + del download_plan, report + calls.append("download") + + def _clear_api_caches_for_run() -> None: + """Record run-cache clearing hook invocation.""" + calls.append("clear_run") + + loader = dummy_downloader() + monkeypatch.setattr(loader, "_prepare_download_plan", _prepare_download_plan) + monkeypatch.setattr(loader, "_download", _download) + monkeypatch.setattr(loader, "_clear_api_caches_for_run", _clear_api_caches_for_run) + loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=10, + ) + + assert calls == ["clear_run", "download", "clear_run"] + + +def test_download_raises_interrupted_error_with_partial_summary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupted runs raise partial-summary wrapper error.""" + + def _prepare_download_plan(*args: Any) -> DownloadPlan: + """Return deterministic download plan.""" + del args + return _download_plan(_title_plan(title_id=42, chapter_ids={1})) + + def _download(download_plan: DownloadPlan, report: RunReport) -> None: + """Mark counters, then emulate user interrupt.""" + del download_plan + report.mark_downloaded() + report.mark_manifest_skipped(2) + report.mark_failed(99) + raise KeyboardInterrupt + + loader = dummy_downloader() + monkeypatch.setattr(loader, "_prepare_download_plan", _prepare_download_plan) + monkeypatch.setattr(loader, "_download", _download) + + with pytest.raises(DownloadInterruptedError) as interrupted: + loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=10, + ) + + assert interrupted.value.summary == DownloadSummary( + downloaded=1, + skipped_manifest=2, + failed=1, + failed_chapter_ids=(99,), + ) + + +def test_download_iterates_titles(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify _download iterates titles in insertion order with indexes.""" + calls: list[tuple[int, int, int, frozenset[int]]] = [] + + def _process_title( + title_index: int, + total_titles: int, + title_plan: TitleDownloadPlan, + *, + report: RunReport, + ) -> None: + """Record _process_title invocation payloads.""" + del report + calls.append((title_index, total_titles, title_plan.title_id, title_plan.chapter_ids)) + + loader = dummy_downloader() + monkeypatch.setattr(loader, "_process_title", _process_title) + loader._download( + DownloadPlan( + title_plans=( + _title_plan(title_id=10, chapter_ids={1, 2}), + _title_plan(title_id=20, chapter_ids={3}), + ) + ), + report=_run_report(), + ) + + assert calls == [(1, 2, 10, frozenset({1, 2})), (2, 2, 20, frozenset({3}))] + + +def test_execution_service_processes_pages_through_page_image_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify execution page export delegates plain and encrypted images to image service.""" + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Any: + del kwargs + yield items + + class Exporter: + def __init__(self) -> None: + self.images: list[bytes] = [] + + def skip_image(self, index: int | range) -> bool: + del index + return False + + def add_image(self, image_data: bytes, index: int | range) -> None: + del index + self.images.append(image_data) + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + exporter = Exporter() + loader = dummy_downloader() + + loader._process_chapter_pages( + [ + _manga_page("plain", page_type=PageType.SINGLE), + _manga_page("encrypted", page_type=PageType.SINGLE, encryption_key="abcd"), + ], + chapter_name="#1", + exporter=exporter, + ) + + assert exporter.images == [b"img:plain", b"dec:encrypted:abcd"] + + +def test_execution_service_dumps_title_metadata_and_cover(tmp_path: Path) -> None: + """Verify execution service wires metadata and cover exporters with configured transport.""" + image_bytes = BytesIO() + Image.new("RGBA", (1, 1), (255, 0, 0, 128)).save(image_bytes, format="PNG") + loader = full_downloader( + destination=str(tmp_path), + cover_format="webp", + response=DummyResponse(content=image_bytes.getvalue()), + ) + title_detail = _title_detail( + title_image_url="https://img/main.webp", + name="Title", + chapters=[], + ) + + loader._dump_title_metadata( + title_detail, + {1: ChapterMetadata(thumbnail_url="", chapter_id=1, sub_title="Sub")}, + tmp_path, + ) + loader._dump_title_cover(title_detail, tmp_path) + + assert (tmp_path / "title_metadata.json").exists() + assert (tmp_path / "cover.webp").exists() diff --git a/tests/test_downloader_title_assets.py b/tests/test_downloader_title_assets.py new file mode 100644 index 0000000..7a80df2 --- /dev/null +++ b/tests/test_downloader_title_assets.py @@ -0,0 +1,211 @@ +"""Tests for downloader title metadata and cover asset export.""" + +from __future__ import annotations + +import json +from io import BytesIO +from pathlib import Path +from typing import cast + +import pytest +from PIL import Image + +from mloader.domain.requests import CoverFormat +from mloader.manga_loader.chapter_planning import ChapterMetadata, ChapterPlanner +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.manga_loader.title_assets import CoverExporter, MetadataExporter +from tests.downloader_helpers import ( + chapter as _chapter, + title_detail as _title_detail, +) + + +def test_dump_title_metadata_writes_expected_json(tmp_path: Path) -> None: + """Verify metadata exporter writes normalized chapter metadata JSON.""" + title_detail = _title_detail( + name="my manga", + author="author", + portrait_image_url="http://img", + number_of_views=321, + chapters=[_chapter(1, "#1", "hello/world", thumbnail_url="t1")], + ) + + export_dir = tmp_path / "My Manga" + chapter_data = ChapterPlanner.extract_chapter_data( + title_detail, + FilenamePolicy.prepare_filename, + ) + MetadataExporter.dump_title_metadata(title_detail, chapter_data, export_dir) + + metadata_file = export_dir / "title_metadata.json" + assert metadata_file.exists() + + content = json.loads(metadata_file.read_text(encoding="utf-8")) + assert content["name"] == "my manga" + assert content["author"] == "author" + assert content["chapters"]["1"]["chapter_id"] == 1 + assert content["chapters"]["1"]["sub_title"] == "Hello World" + + +def test_dump_title_metadata_supports_explicit_chapter_mapping(tmp_path: Path) -> None: + """Verify explicit ``chapter_data`` + ``export_dir`` writes metadata output.""" + title_detail = _title_detail( + name="my manga", + author="author", + portrait_image_url="http://img", + number_of_views=1, + chapters=[], + ) + chapter_data = {1024959: ChapterMetadata(thumbnail_url="t1", chapter_id=1024959, sub_title="A")} + export_dir = tmp_path / "My Manga" + + MetadataExporter.dump_title_metadata(title_detail, chapter_data, export_dir) + + content = json.loads((export_dir / "title_metadata.json").read_text(encoding="utf-8")) + assert content["chapters"]["1024959"]["chapter_id"] == 1024959 + assert content["chapters"]["1024959"]["sub_title"] == "A" + + +def test_resolve_cover_image_url_prefers_portrait_and_falls_back_to_main() -> None: + """Verify cover URL resolution prefers portrait image URL over title image URL.""" + with_main = _title_detail( + title_image_url="https://img/main.webp", + portrait_image_url="https://img/portrait.webp", + landscape_image_url="https://img/landscape.webp", + ) + without_main = _title_detail( + title_image_url="", + portrait_image_url="https://img/portrait.webp", + landscape_image_url="https://img/landscape.webp", + ) + + assert CoverExporter.resolve_cover_image_url(with_main) == "https://img/portrait.webp" + assert CoverExporter.resolve_cover_image_url(without_main) == "https://img/portrait.webp" + + +def test_resolve_cover_image_url_falls_back_to_landscape_then_none() -> None: + """Verify cover URL resolution uses landscape fallback and returns None when absent.""" + landscape_only = _title_detail( + title_image_url="", + portrait_image_url="", + landscape_image_url="https://img/landscape.webp", + ) + no_cover = _title_detail( + title_image_url="", + portrait_image_url="", + landscape_image_url="", + ) + + assert CoverExporter.resolve_cover_image_url(landscape_only) == "https://img/landscape.webp" + assert CoverExporter.resolve_cover_image_url(no_cover) is None + + +@pytest.mark.parametrize( + ("cover_format", "expected_pil_format", "expected_mode"), + [ + ("png", "PNG", "RGBA"), + ("jpg", "JPEG", "RGB"), + ("webp", "WEBP", "RGBA"), + ], +) +def test_dump_title_cover_exports_selected_format( + tmp_path: Path, + cover_format: CoverFormat, + expected_pil_format: str, + expected_mode: str, +) -> None: + """Verify cover export downloads bytes and stores the selected image format.""" + image_bytes = BytesIO() + Image.new("RGBA", (1, 1), (255, 0, 0, 128)).save(image_bytes, format="PNG") + image_blob = image_bytes.getvalue() + + title_detail = _title_detail( + title_image_url="https://img/main.webp", + name="my manga", + portrait_image_url="", + landscape_image_url="", + ) + export_dir = tmp_path / "My Manga" + + CoverExporter.dump_title_cover( + title_detail, + export_dir, + cover_format=cover_format, + download_image=lambda _url: image_blob, + ) + + cover_path = export_dir / f"cover.{cover_format}" + assert cover_path.exists() + with Image.open(cover_path) as image: + assert image.format == expected_pil_format + assert image.mode == expected_mode + + +def test_dump_title_cover_raises_on_invalid_cover_format( + tmp_path: Path, +) -> None: + """Verify defensive validation catches unsupported cover formats.""" + image_bytes = BytesIO() + Image.new("RGB", (1, 1), (255, 0, 0)).save(image_bytes, format="JPEG") + image_blob = image_bytes.getvalue() + + title_detail = _title_detail( + title_image_url="https://img/main.webp", + name="my manga", + portrait_image_url="", + landscape_image_url="", + ) + + with pytest.raises(ValueError, match="Unsupported cover format: bmp"): + CoverExporter.dump_title_cover( + title_detail, + tmp_path / "My Manga", + cover_format=cast(CoverFormat, "bmp"), + download_image=lambda _url: image_blob, + ) + + +def test_dump_title_cover_skips_when_cover_url_is_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify cover export logs and returns when no cover URL is available.""" + title_detail = _title_detail( + title_image_url="", + name="my manga", + portrait_image_url="", + landscape_image_url="", + ) + + CoverExporter.dump_title_cover( + title_detail, + tmp_path / "My Manga", + cover_format="png", + download_image=lambda _url: b"", + ) + + assert "Cover export skipped" in caplog.text + + +def test_dump_title_cover_skips_when_selected_cover_file_already_exists( + tmp_path: Path, +) -> None: + """Verify cover export does not re-download when selected cover file exists.""" + title_detail = _title_detail( + title_image_url="https://img/main.webp", + name="my manga", + portrait_image_url="", + landscape_image_url="", + ) + export_dir = tmp_path / "My Manga" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "cover.webp").write_bytes(b"already") + + CoverExporter.dump_title_cover( + title_detail, + export_dir, + cover_format="webp", + download_image=lambda _url: (_ for _ in ()).throw( + AssertionError("cover should not be downloaded twice") + ), + ) diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py new file mode 100644 index 0000000..e27876f --- /dev/null +++ b/tests/test_entrypoints.py @@ -0,0 +1,11 @@ +"""Tests for importable runtime entrypoint modules.""" + +from __future__ import annotations + +import importlib + + +def test_import_mloader_dunder_main_module() -> None: + """Verify the ``python -m`` entrypoint module can be imported.""" + module = importlib.reload(importlib.import_module("mloader.__main__")) + assert callable(module.main) diff --git a/tests/test_error_mapping.py b/tests/test_error_mapping.py new file mode 100644 index 0000000..aee7137 --- /dev/null +++ b/tests/test_error_mapping.py @@ -0,0 +1,76 @@ +"""Tests for typed failure taxonomy and CLI boundary mappings.""" + +from __future__ import annotations + +from mloader.application.errors import DiscoveryError, DownloadInterrupted, ExternalDependencyError +from mloader.cli.error_mapping import cli_failure_mapping, partial_download_failure_mapping +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG +from mloader.domain.requests import DownloadSummary +from mloader.errors import APIResponseError, SubscriptionRequiredError +from mloader.errors import DownloadInterruptedError + + +def test_domain_errors_expose_failure_kind_metadata() -> None: + """Verify runtime errors carry stable failure-kind metadata.""" + api_error = APIResponseError("schema drift", kind="api_error", code="10511") + subscription_error = SubscriptionRequiredError("subscription required") + interrupted_error = DownloadInterruptedError( + DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + ) + + assert api_error.error_kind == "external_dependency" + assert api_error.kind == "api_error" + assert api_error.api_error_kind == "api_error" + assert api_error.code == "10511" + assert subscription_error.error_kind == "subscription_required" + assert interrupted_error.error_kind == "interrupted" + + +def test_application_errors_expose_failure_kind_metadata() -> None: + """Verify application errors carry stable failure-kind metadata.""" + summary = DownloadSummary( + downloaded=1, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(77,), + ) + + assert DiscoveryError("discovery").error_kind == "external_dependency" + assert ExternalDependencyError("network").error_kind == "external_dependency" + interrupted = DownloadInterrupted(summary) + assert interrupted.error_kind == "interrupted" + assert interrupted.summary is summary + + +def test_cli_failure_mapping_uses_failure_kind_metadata() -> None: + """Verify CLI boundaries derive exit/report behavior from failure kinds.""" + subscription_mapping = cli_failure_mapping(SubscriptionRequiredError("subscription")) + external_mapping = cli_failure_mapping(ExternalDependencyError("network")) + interrupted_mapping = cli_failure_mapping( + DownloadInterrupted( + DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + ) + ) + internal_mapping = cli_failure_mapping(RuntimeError("boom")) + partial_mapping = partial_download_failure_mapping() + + assert subscription_mapping.exit_code == EXTERNAL_FAILURE + assert subscription_mapping.subscription_access_failures == 1 + assert subscription_mapping.report_status == "error" + assert external_mapping.exit_code == EXTERNAL_FAILURE + assert interrupted_mapping.error_kind == "interrupted" + assert interrupted_mapping.exit_code == EXTERNAL_FAILURE + assert internal_mapping.error_kind == "internal_bug" + assert internal_mapping.exit_code == INTERNAL_BUG + assert partial_mapping.error_kind == "external_dependency" + assert partial_mapping.exit_code == EXTERNAL_FAILURE diff --git a/tests/test_exporter_base.py b/tests/test_exporter_base.py new file mode 100644 index 0000000..9ef8573 --- /dev/null +++ b/tests/test_exporter_base.py @@ -0,0 +1,228 @@ +"""Tests for ExporterBase shared naming and registration behavior.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from mloader.constants import Language +from mloader.domain.manga import Chapter, Title +from mloader.exporters.exporter_base import ExporterBase, _is_extra + + +class DummyExporter(ExporterBase): + """Minimal exporter implementation used for ExporterBase tests.""" + + format = "dummy" + + def add_image(self, image_data: bytes, index: int | range) -> None: + """Store the latest add_image call payload for assertions.""" + self.last = (image_data, index) + + def skip_image(self, index: int | range) -> bool: + """Never skip any images in this test exporter.""" + del index + return False + + +def _title( + name: str = "demo title", + language: int = Language.ENGLISH.value, + author: str = "author", +) -> SimpleNamespace: + """Build a minimal title object for exporter-base tests.""" + return SimpleNamespace( + name=name, + language=language, + author=author, + portrait_image_url="", + landscape_image_url="", + ) + + +def _chapter(name: str = "#1", sub_title: str = "subtitle") -> SimpleNamespace: + """Build a minimal chapter object for exporter-base tests.""" + return SimpleNamespace(chapter_id=1, name=name, sub_title=sub_title, thumbnail_url="") + + +def test_is_extra_detection() -> None: + """Verify extra chapter detection for Ex and numeric chapters.""" + assert _is_extra("#Ex") is True + assert _is_extra("#1") is False + + +def test_exporter_base_formats_prefix_suffix_and_page_names(tmp_path: Path) -> None: + """Verify exporter base derives expected prefixes, suffixes, and page names.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=Language.FRENCH.value), + chapter=_chapter(name="#12", sub_title=""), + ) + + assert "[FRENCH]" in exporter._chapter_prefix + assert exporter._chapter_suffix == "- Unknown" + assert exporter.format_page_name(2) == f"{exporter._chapter_prefix} - p002 - Unknown.jpg" + assert exporter.format_page_name(range(1, 4), ext="png").endswith(".png") + + +@pytest.mark.parametrize( + ("title", "chapter", "page", "expected_name"), + [ + ( + _title(name="demo title", language=Language.ENGLISH.value), + _chapter(name="#12", sub_title="A/B: C?"), + 2, + "Demo Title - 12 - p002 - A B C.jpg", + ), + ( + _title(name="demo title", language=Language.FRENCH.value), + _chapter(name="#12", sub_title=""), + 2, + "Demo Title [FRENCH] - 12 - p002 - Unknown.jpg", + ), + ( + _title(name="demo title", language=Language.ENGLISH.value), + _chapter(name="#7", sub_title="Double Page"), + range(10, 12), + "Demo Title - 7 - p010-012 - Double Page.jpg", + ), + ( + _title(name="demo title", language=Language.ENGLISH.value), + _chapter(name="#Ex", sub_title="Bonus"), + 1, + "Demo Title - Ex - p001 - Bonus.jpg", + ), + ( + _title(name="demo title", language=Language.ENGLISH.value), + _chapter(name="One Shot Special", sub_title=""), + 1, + "Demo Title - One Shot Special - p001 - Unknown.jpg", + ), + ], +) +def test_exporter_base_golden_page_names( + tmp_path: Path, + title: SimpleNamespace, + chapter: SimpleNamespace, + page: int | range, + expected_name: str, +) -> None: + """Verify golden filename behavior for languages, extras, one-shots, and ranges.""" + exporter = DummyExporter(destination=str(tmp_path), title=title, chapter=chapter) + + assert exporter.format_page_name(page) == expected_name + assert exporter.is_extra is _is_extra(chapter.name) + if chapter.name == "One Shot Special": + assert exporter.is_oneshot is True + + +def test_exporter_base_accepts_domain_title_and_chapter_dtos(tmp_path: Path) -> None: + """Verify shared exporter naming only depends on the stable domain DTO shape.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=Title( + title_id=100494, + name="domain title", + author="Domain Author", + portrait_image_url="https://example.invalid/portrait.webp", + landscape_image_url="https://example.invalid/landscape.webp", + language=Language.ENGLISH.value, + ), + chapter=Chapter( + title_id=100494, + chapter_id=1024974, + name="#001", + sub_title="Domain Chapter", + thumbnail_url="https://example.invalid/chapter.webp", + ), + ) + + assert exporter.format_page_name(1) == "Domain Title - 001 - p001 - Domain Chapter.jpg" + + +def test_exporter_base_handles_alternate_vietnamese_language_code(tmp_path: Path) -> None: + """Verify alternate Vietnamese code still produces a stable language prefix.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=8), + chapter=_chapter(), + ) + + assert "[VIETNAMESE]" in exporter._chapter_prefix + + +def test_exporter_base_handles_unknown_language_code(tmp_path: Path) -> None: + """Verify unknown language codes do not raise and keep a readable tag.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=99), + chapter=_chapter(), + ) + + assert "[LANG-99]" in exporter._chapter_prefix + + +def test_exporter_base_iso_language_maps_known_code(tmp_path: Path) -> None: + """Verify known internal language codes map to expected ISO values.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=Language.SPANISH.value), + chapter=_chapter(), + ) + + assert exporter._iso_language() == "es" + + +def test_exporter_base_iso_language_falls_back_to_english(tmp_path: Path) -> None: + """Verify unknown language codes default to English ISO code.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=99), + chapter=_chapter(), + ) + + assert exporter._iso_language() == "en" + + +def test_exporter_base_windows_path_prefix( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify Windows mode adds extended-length prefix to destination paths.""" + monkeypatch.setattr("mloader.exporters.exporter_base.is_windows", lambda: True) + + exporter = DummyExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + assert exporter.destination.startswith("\\\\?\\") + + +def test_exporter_base_registers_subclasses_and_close_pass(tmp_path: Path) -> None: + """Verify subclasses register format keys and default close is callable.""" + exporter = DummyExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + exporter.close() + + assert ExporterBase.FORMAT_REGISTRY["dummy"] is DummyExporter + + +def test_abstract_base_methods_have_noop_defaults() -> None: + """Verify abstract base placeholders are callable for coverage purposes.""" + exporter = DummyExporter(destination="/tmp", title=_title(), chapter=_chapter()) + ExporterBase.add_image(exporter, b"", 0) + ExporterBase.skip_image(exporter, 0) + + +def test_exporter_base_requires_non_empty_format() -> None: + """Verify subclass registration rejects missing/empty format keys.""" + with pytest.raises(TypeError): + + class _InvalidExporter(ExporterBase): + format = "" + + def add_image(self, image_data: bytes, index: int | range) -> None: + del image_data, index + + def skip_image(self, index: int | range) -> bool: + del index + return False diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 0000000..59efc7b --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,439 @@ +"""Tests for concrete exporter implementations.""" + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path +from types import SimpleNamespace + +from PIL import Image +import pytest + +from mloader.constants import Language +from mloader.domain.manga import Chapter, Title, TitleTag +from mloader.exporters.cbz_exporter import CBZExporter +from mloader.exporters.pdf_exporter import PDFExporter +from mloader.exporters.raw_exporter import RawExporter + + +def _title( + name: str = "demo title", + language: int = Language.ENGLISH.value, + author: str = "author", + overview: str = "", + tags: tuple[TitleTag, ...] = (), + web_url: str = "", +) -> SimpleNamespace: + """Build a minimal title object for exporter tests.""" + return SimpleNamespace( + name=name, + language=language, + author=author, + overview=overview, + tags=tags, + web_url=web_url, + ) + + +def _chapter( + name: str = "#1", sub_title: str = "start", start_timestamp: int = 0 +) -> SimpleNamespace: + """Build a minimal chapter object for exporter tests.""" + return SimpleNamespace(name=name, sub_title=sub_title, start_timestamp=start_timestamp) + + +def _domain_title() -> Title: + """Build a stable domain title DTO for exporter contract tests.""" + return Title( + title_id=100494, + name="domain title", + author="Domain Author", + portrait_image_url="https://example.invalid/portrait.webp", + landscape_image_url="https://example.invalid/landscape.webp", + language=Language.ENGLISH.value, + ) + + +def _domain_chapter() -> Chapter: + """Build a stable domain chapter DTO for exporter contract tests.""" + return Chapter( + title_id=100494, + chapter_id=1024974, + name="#001", + sub_title="Domain Chapter", + thumbnail_url="https://example.invalid/chapter.webp", + ) + + +def _jpeg_bytes(color: tuple[int, int, int] = (255, 0, 0)) -> bytes: + """Create a small in-memory JPEG image payload for tests.""" + image = Image.new("RGB", (20, 20), color=color) + buffer = io.BytesIO() + image.save(buffer, format="JPEG") + return buffer.getvalue() + + +def _png_rgba_bytes() -> bytes: + """Create a small RGBA PNG payload for PDF conversion branch tests.""" + image = Image.new("RGBA", (20, 20), color=(255, 0, 0, 120)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def test_raw_exporter_writes_and_skips_existing_image(tmp_path: Path) -> None: + """Verify raw exporter writes page files and skips existing outputs.""" + exporter = RawExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"abc", 0) + filename = exporter.format_page_name(0) + path = exporter.path / filename + + assert path.exists() + assert path.read_bytes() == b"abc" + assert exporter.skip_image(0) is True + + +def test_concrete_exporters_accept_domain_dtos(tmp_path: Path) -> None: + """Verify exporters write outputs from stable domain DTOs, not protobuf instances.""" + title = _domain_title() + chapter = _domain_chapter() + + raw = RawExporter(destination=str(tmp_path), title=title, chapter=chapter) + raw.add_image(b"raw", 1) + assert (raw.path / raw.format_page_name(1)).read_bytes() == b"raw" + + cbz = CBZExporter(destination=str(tmp_path), title=title, chapter=chapter) + cbz.add_image(b"cbz", 1) + cbz.close() + assert cbz.path.name == "Domain Title - 001 - Domain Chapter.cbz" + assert cbz.path.exists() + + pdf = PDFExporter(destination=str(tmp_path), title=title, chapter=chapter) + pdf.add_image(_jpeg_bytes(), 1) + pdf.close() + assert pdf.path.name == "Domain Title - 001 - Domain Chapter.pdf" + assert pdf.path.exists() + + +def test_raw_exporter_with_chapter_subdir(tmp_path: Path) -> None: + """Verify raw exporter can place output in chapter-specific subdirectories.""" + exporter = RawExporter( + destination=str(tmp_path), + title=_title(), + chapter=_chapter(), + add_chapter_subdir=True, + ) + assert exporter.path.name == exporter.chapter_name + + +def test_cbz_exporter_creates_archive_with_images(tmp_path: Path) -> None: + """Verify CBZ exporter writes image entries into a created archive.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"img1", 0) + exporter.add_image(b"img2", 1) + exporter.close() + + assert exporter.path.exists() + + with zipfile.ZipFile(exporter.path, "r") as archive: + names = set(archive.namelist()) + comicinfo = archive.read("ComicInfo.xml").decode("utf-8") + + assert names == { + exporter.format_page_name(0), + exporter.format_page_name(1), + "ComicInfo.xml", + } + assert all("/" not in name for name in names) + assert "" in comicinfo + assert "en" in comicinfo + assert "2" in comicinfo + assert "Digital" in comicinfo + + +def test_cbz_exporter_comicinfo_escapes_metadata(tmp_path: Path) -> None: + """Verify ComicInfo.xml escapes special characters from metadata.""" + exporter = CBZExporter( + destination=str(tmp_path), + title=_title( + name="a & b", + author="x < y", + overview="summary & strong", + tags=(TitleTag(name="Action & Adventure", slug="action"),), + web_url="https://example.invalid/a?b=1&c=2", + ), + chapter=_chapter(name="#7", sub_title='title "quoted"', start_timestamp=1747407600), + ) + + exporter.add_image(b"img", 0) + exporter.close() + + with zipfile.ZipFile(exporter.path, "r") as archive: + comicinfo = archive.read("ComicInfo.xml").decode("utf-8") + + assert "a & b" in comicinfo + assert "x < y" in comicinfo + assert "title "quoted"" in comicinfo + assert "summary <quoted> & strong" in comicinfo + assert "Action & Adventure" in comicinfo + assert "Action & Adventure" in comicinfo + assert "https://example.invalid/a?b=1&c=2" in comicinfo + assert "2025" in comicinfo + assert "5" in comicinfo + assert "16" in comicinfo + + +def test_cbz_exporter_omits_missing_optional_comicinfo_metadata(tmp_path: Path) -> None: + """Verify empty MangaPlus metadata does not produce empty ComicInfo elements.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"img", 0) + exporter.close() + + with zipfile.ZipFile(exporter.path, "r") as archive: + comicinfo = archive.read("ComicInfo.xml").decode("utf-8") + + assert "" not in comicinfo + assert "" not in comicinfo + assert "" not in comicinfo + assert "" not in comicinfo + assert "" not in comicinfo + assert "" not in comicinfo + assert "Manga" in comicinfo + + +def test_cbz_exporter_comicinfo_write_is_idempotent(tmp_path: Path) -> None: + """Verify repeated ComicInfo writes do not create duplicate archive entries.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"img", 0) + exporter._write_comicinfo_xml_entry() + exporter._write_comicinfo_xml_entry() + exporter.close() + + with zipfile.ZipFile(exporter.path, "r") as archive: + names = archive.namelist() + + assert names.count("ComicInfo.xml") == 1 + + +def test_cbz_exporter_skips_when_archive_exists(tmp_path: Path) -> None: + """Verify CBZ exporter skips writes when destination archive already exists.""" + first = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + first.add_image(b"img1", 0) + first.close() + + second = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + size_before = second.path.stat().st_size + + second.add_image(b"ignored", 1) + assert second.skip_image(0) is True + second.close() + + assert second.path.stat().st_size == size_before + + +def test_cbz_exporter_cleans_temp_archive_when_close_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify failed CBZ finalization does not leave a corrupt final archive.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + exporter.add_image(b"img", 0) + temp_path = exporter._temp_path + + def _raise_comicinfo_error() -> None: + raise RuntimeError("comicinfo failed") + + monkeypatch.setattr(exporter, "_write_comicinfo_xml_entry", _raise_comicinfo_error) + + with pytest.raises(RuntimeError, match="comicinfo failed"): + exporter.close() + + assert exporter.path.exists() is False + assert temp_path is not None + assert temp_path.exists() is False + + +def test_cbz_exporter_close_handles_missing_temp_path(tmp_path: Path) -> None: + """Verify defensive CBZ close path exits when temp path state is missing.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + exporter.add_image(b"img", 0) + temp_path = exporter._temp_path + exporter._temp_path = None + + exporter.close() + + assert exporter.path.exists() is False + assert temp_path is not None + temp_path.unlink(missing_ok=True) + + +def test_cbz_exporter_discard_removes_temp_archive(tmp_path: Path) -> None: + """Verify CBZ discard cleans partial archives before close.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + exporter.add_image(b"img", 0) + temp_path = exporter._temp_path + + exporter.discard() + + assert exporter.path.exists() is False + assert temp_path is not None + assert temp_path.exists() is False + + +def test_cbz_exporter_discard_noops_when_output_exists(tmp_path: Path) -> None: + """Verify discard respects existing archive skip mode.""" + first = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + first.add_image(b"img", 0) + first.close() + + second = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + second.discard() + + assert second.path.exists() is True + + +def test_pdf_exporter_writes_pdf(tmp_path: Path) -> None: + """Verify PDF exporter writes a non-empty PDF output file.""" + exporter = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(_jpeg_bytes(), 0) + exporter.close() + + assert exporter.path.exists() + assert exporter.path.stat().st_size > 0 + + +def test_pdf_exporter_skips_when_pdf_exists(tmp_path: Path) -> None: + """Verify PDF exporter skips writes when destination PDF already exists.""" + first = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + first.add_image(_jpeg_bytes(), 0) + first.close() + + second = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + size_before = second.path.stat().st_size + + second.add_image(_jpeg_bytes(), 0) + assert second.skip_image(0) is True + second.close() + + assert second.path.stat().st_size == size_before + + +def test_pdf_exporter_close_without_images_is_noop(tmp_path: Path) -> None: + """Verify closing PDF exporter without images does not create output.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="other"), chapter=_chapter() + ) + exporter.close() + assert exporter.path.exists() is False + + +def test_pdf_exporter_cleans_temp_page_buffers_after_close(tmp_path: Path) -> None: + """Verify PDF exporter releases temporary buffering state once closed.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="buffers"), chapter=_chapter() + ) + + exporter.add_image(_jpeg_bytes(), 5) + exporter.add_image(_jpeg_bytes(color=(0, 255, 0)), 1) + exporter.close() + + assert exporter.path.exists() is True + assert exporter._temp_dir is None + assert exporter._page_paths == [] + + +def test_pdf_exporter_add_image_noops_when_temp_dir_is_missing(tmp_path: Path) -> None: + """Verify add_image exits cleanly when temp buffering is unexpectedly unavailable.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="no-temp"), chapter=_chapter() + ) + exporter._temp_dir = None + exporter.close() + + exporter.add_image(_jpeg_bytes(), 0) + + assert exporter._page_paths == [] + + +def test_pdf_exporter_build_inputs_without_temp_dir_returns_empty(tmp_path: Path) -> None: + """Verify defensive PDF input builder handles missing temp state.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="no-inputs"), chapter=_chapter() + ) + exporter._temp_dir = None + + assert exporter._build_pdf_inputs() == [] + + +def test_pdf_exporter_close_without_pdf_inputs_is_noop( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify close exits cleanly when no prepared PDF inputs remain.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="empty-inputs"), chapter=_chapter() + ) + exporter.add_image(_jpeg_bytes(), 0) + monkeypatch.setattr(exporter, "_build_pdf_inputs", lambda: []) + + exporter.close() + + assert exporter.path.exists() is False + + +def test_pdf_exporter_handles_rgba_images_and_range_index(tmp_path: Path) -> None: + """Verify PDF exporter converts RGBA pages and accepts range-based page indexes.""" + exporter = PDFExporter(destination=str(tmp_path), title=_title(name="rgba"), chapter=_chapter()) + + exporter.add_image(_png_rgba_bytes(), range(1, 2)) + exporter.close() + + assert exporter.path.exists() is True + assert exporter.path.stat().st_size > 0 + + +def test_pdf_exporter_cleans_temp_files_when_conversion_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify failed PDF conversion leaves no final corrupt artifact.""" + exporter = PDFExporter(destination=str(tmp_path), title=_title(name="fail"), chapter=_chapter()) + exporter.add_image(_jpeg_bytes(), 0) + temp_dir = exporter._temp_dir + + def _raise_convert(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("convert failed") + + monkeypatch.setattr("mloader.exporters.pdf_exporter.img2pdf.convert", _raise_convert) + + with pytest.raises(RuntimeError, match="convert failed"): + exporter.close() + + assert exporter.path.exists() is False + assert exporter._temp_dir is None + assert exporter._page_paths == [] + assert temp_dir is not None + assert Path(temp_dir.name).exists() is False + + +def test_pdf_exporter_discard_removes_buffered_pages(tmp_path: Path) -> None: + """Verify PDF discard cleans page buffers before close.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="discard"), chapter=_chapter() + ) + exporter.add_image(_jpeg_bytes(), 0) + temp_dir = exporter._temp_dir + + exporter.discard() + + assert exporter.path.exists() is False + assert exporter._temp_dir is None + assert exporter._page_paths == [] + assert temp_dir is not None + assert Path(temp_dir.name).exists() is False diff --git a/tests/test_filename_policy.py b/tests/test_filename_policy.py new file mode 100644 index 0000000..ddae575 --- /dev/null +++ b/tests/test_filename_policy.py @@ -0,0 +1,51 @@ +"""Tests for centralized downloader filename policy.""" + +from __future__ import annotations + +from mloader.manga_loader.filename_policy import FilenamePolicy +from tests.downloader_helpers import chapter as _chapter + + +def test_filename_policy_preserves_title_directory_contract() -> None: + """Verify title directory naming keeps the existing title-case sanitized contract.""" + assert FilenamePolicy.title_directory_name("dr. STONE") == "Dr Stone" + + +def test_filename_policy_preserves_chapter_stem_contract() -> None: + """Verify chapter filename stems match existing golden naming behavior.""" + assert ( + FilenamePolicy.build_expected_filename( + "Dr Stone", + _chapter(1000311, "#002"), + "Z=2 Fantasy vs. Science?", + ) + == "Dr Stone - 002 - Z 2 Fantasy vs Science" + ) + + +def test_build_expected_filename_legacy_style_without_language_tag() -> None: + """Verify legacy mode keeps the old filename stem shape with no language tag.""" + assert ( + FilenamePolicy.build_expected_filename( + "Dr Stone", + _chapter(1000311, "#002"), + "Z=2 Fantasy vs. Science?", + 8, + filename_style="legacy", + ) + == "Dr Stone - 002 - Z 2 Fantasy vs Science" + ) + + +def test_build_expected_filename_new_style_appends_language_tag() -> None: + """Verify new mode appends the language tag in title-level chapter filenames.""" + assert ( + FilenamePolicy.build_expected_filename( + "Dr Stone", + _chapter(1000311, "#002"), + "Z=2 Fantasy vs. Science?", + 8, + filename_style="new", + ) + == "Dr Stone [VIETNAMESE] - 002 - Z 2 Fantasy vs Science" + ) diff --git a/tests/test_manga_loader_init.py b/tests/test_manga_loader_init.py new file mode 100644 index 0000000..febe783 --- /dev/null +++ b/tests/test_manga_loader_init.py @@ -0,0 +1,161 @@ +"""Tests for MangaLoader initialization behavior.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest +from requests import Session + +from mloader.domain.manga import Chapter, ChapterGroup, Title, TitleDetail +from mloader.infrastructure.mangaplus.transport import configure_transport +from mloader.manga_loader.init import MangaLoader +from mloader.manga_loader.runner import DownloadRunner +from mloader.types import SessionLike +from tests.downloader_helpers import NullExporterFactory + + +EXPORTER_FACTORY = NullExporterFactory("mloader_downloads") + + +def _title_detail() -> TitleDetail: + """Build a minimal title detail for runtime planning tests.""" + chapter = Chapter( + title_id=100001, + chapter_id=200001, + name="#001", + sub_title="A Beginning", + thumbnail_url="https://example.test/chapter.webp", + ) + return TitleDetail( + title=Title( + title_id=100001, + name="Demo", + author="Author", + portrait_image_url="https://example.test/portrait.webp", + landscape_image_url="https://example.test/landscape.webp", + language=0, + ), + title_image_url="https://example.test/title.webp", + overview="Overview", + non_appearance_info="", + number_of_views=1, + chapter_groups=( + ChapterGroup(first_chapters=(chapter,), mid_chapters=(), last_chapters=()), + ), + ) + + +def test_manga_loader_creates_independent_default_sessions() -> None: + """Ensure separate MangaLoader instances do not share default sessions.""" + loader_a = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + loader_b = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + + assert loader_a.session is not loader_b.session + assert loader_a.session.headers["User-Agent"] == "okhttp/4.12.0" + assert "Host" not in loader_a.session.headers + + +def test_manga_loader_configures_transport_defaults() -> None: + """Ensure loader sets destination, format, and timeout defaults.""" + session = Session() + loader = MangaLoader( + exporter=EXPORTER_FACTORY, + quality="high", + split=False, + meta=False, + session=cast(SessionLike, session), + ) + + assert loader.destination == "mloader_downloads" + assert loader.output_format == "cbz" + assert loader.request_timeout == (5.0, 30.0) + assert session.get_adapter("https://").max_retries.total == 3 + assert loader._runtime.resume is True + assert loader._runtime.manifest_reset is False + assert loader._runtime.cover_format == "png" + + +def test_runtime_uses_concrete_runner() -> None: + """Ensure the live runtime uses the canonical concrete runner.""" + loader = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + + assert type(loader._runtime) is DownloadRunner + + +def test_runtime_prepares_domain_download_plan(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure the runtime resolves filters through its concrete planning hook.""" + loader = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + + monkeypatch.setattr(loader._runtime, "_get_title_details", lambda _title_id: _title_detail()) + + plan = loader._runtime._prepare_download_plan( + title_ids={100001}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=10, + last_chapter=False, + ) + + assert plan.title_count == 1 + assert plan.title_plans[0].chapter_ids == frozenset({200001}) + + +def test_manga_loader_enables_payload_capture_when_directory_is_set(tmp_path: Path) -> None: + """Ensure loader initializes payload capture helper when configured.""" + loader = MangaLoader( + exporter=EXPORTER_FACTORY, + quality="high", + split=False, + meta=False, + capture_api_dir=str(tmp_path / "captures"), + ) + + assert loader.payload_capture is not None + + +def test_runtime_transport_helper_configures_retries() -> None: + """Ensure runtime transport helper configures retries.""" + session = Session() + + configure_transport(cast(SessionLike, session), retries=4) + + assert session.get_adapter("https://").max_retries.total == 4 + + +def test_manga_loader_download_delegates_to_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure facade download method forwards parameters to composed runtime object.""" + loader = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + observed: dict[str, object] = {} + + def _download(**kwargs: object) -> None: + observed.update(kwargs) + + monkeypatch.setattr(loader._runtime, "download", _download) + + loader.download( + title_ids={100312}, + chapter_ids={1024959}, + min_chapter=3, + max_chapter=4, + last_chapter=True, + ) + + assert observed == { + "title_ids": {100312}, + "chapter_numbers": None, + "chapter_ids": {1024959}, + "min_chapter": 3, + "max_chapter": 4, + "last_chapter": True, + } + + +def test_manga_loader_unknown_attribute_raises_attribute_error() -> None: + """Ensure facade exposes only explicit API and rejects unknown attributes.""" + loader = MangaLoader(exporter=EXPORTER_FACTORY, quality="high", split=False, meta=False) + + with pytest.raises(AttributeError): + getattr(loader, "runtime_marker") diff --git a/tests/test_mangaplus_browser_discovery.py b/tests/test_mangaplus_browser_discovery.py new file mode 100644 index 0000000..c51e5dd --- /dev/null +++ b/tests/test_mangaplus_browser_discovery.py @@ -0,0 +1,90 @@ +"""Tests for browser-rendered MangaPlus list-page discovery.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from mloader.infrastructure.mangaplus import browser_discovery + + +def test_collect_title_ids_with_browser_returns_sorted_unique_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify browser scrape can collect IDs via rendered DOM links.""" + + class DummyLink: + """Link test double with optional href attribute.""" + + def __init__(self, href: str | None) -> None: + """Store href value returned by DOM attribute access.""" + self.href = href + + def get_attribute(self, _name: str) -> str | None: + """Return configured href value.""" + return self.href + + class DummyPage: + """Page test double with deterministic links for all requested pages.""" + + def goto(self, *_args: object, **_kwargs: object) -> None: + """Accept navigation calls without side effects.""" + + def query_selector_all(self, _selector: str) -> list[DummyLink]: + """Return deterministic set of DOM links for extraction.""" + return [ + DummyLink("/titles/100003"), + DummyLink("/titles/100001/"), + DummyLink("/titles/100001"), + DummyLink(None), + ] + + class DummyBrowser: + """Browser test double producing a single page object.""" + + def new_page(self) -> DummyPage: + """Return page test double for scrape actions.""" + return DummyPage() + + def close(self) -> None: + """Support browser lifecycle teardown call.""" + + class DummyChromium: + """Chromium launcher test double.""" + + def launch(self, *, headless: bool) -> DummyBrowser: + """Return browser test double for headless mode.""" + assert headless is True + return DummyBrowser() + + class DummyPlaywright: + """Playwright root object exposing chromium launcher.""" + + chromium = DummyChromium() + + class DummyPlaywrightContext: + """Context-manager test double returned by sync_playwright().""" + + def __enter__(self) -> DummyPlaywright: + """Return playwright object for context manager body.""" + return DummyPlaywright() + + def __exit__(self, *args: object) -> None: + """Support context manager protocol.""" + _ = args + + sync_api_module = types.ModuleType("playwright.sync_api") + setattr(sync_api_module, "sync_playwright", lambda: DummyPlaywrightContext()) + playwright_module = types.ModuleType("playwright") + setattr(playwright_module, "sync_api", sync_api_module) + monkeypatch.setitem(sys.modules, "playwright", playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", sync_api_module) + + result = browser_discovery.collect_title_ids_with_browser( + ["https://a.example", "https://b.example"], + id_length=6, + ) + + assert result == [100001, 100003] diff --git a/tests/test_mangaplus_gateway.py b/tests/test_mangaplus_gateway.py new file mode 100644 index 0000000..5d926be --- /dev/null +++ b/tests/test_mangaplus_gateway.py @@ -0,0 +1,166 @@ +"""Tests for the MangaPlus HTTP gateway adapter.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest + +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import gateway +from mloader.infrastructure.mangaplus import parsing +from mloader.response_pb2 import Response +from mloader.types import SessionLike +from tests.http_fakes import BytesResponse + + +class DummySession(SessionLike): + """HTTP session test double for gateway requests.""" + + def __init__(self) -> None: + """Initialize request recording state.""" + self.headers: dict[str, str] = {} + self.calls: list[tuple[str, dict[str, object] | None]] = [] + self.mounted: list[tuple[str, object]] = [] + + def mount(self, prefix: str, adapter: object) -> None: + """Record mounted adapters.""" + self.mounted.append((prefix, adapter)) + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> BytesResponse: + """Return protobuf payloads based on requested endpoint.""" + del timeout + self.calls.append((url, dict(params) if params is not None else None)) + params = params or {} + if url.endswith("/api/title_detailV3"): + return BytesResponse(_title_detail_payload(int(str(params["title_id"])))) + if url.endswith("/api/manga_viewer"): + return BytesResponse(_viewer_payload(int(str(params["chapter_id"])))) + raise AssertionError(f"Unexpected URL: {url}") + + +class Capture: + """Payload capture test double.""" + + def __init__(self) -> None: + """Initialize capture record storage.""" + self.records: list[dict[str, Any]] = [] + + def capture(self, **kwargs: Any) -> None: + """Store capture call keyword arguments.""" + self.records.append(kwargs) + + +def _title_detail_payload(title_id: int) -> bytes: + """Build a title-detail protobuf payload.""" + response = Response() + title_detail = response.success.title_detail_view + title_detail.title.title_id = title_id + title_detail.title.name = f"Title {title_id}" + chapter = title_detail.chapter_list_group.add().first_chapter_list.add() + chapter.title_id = title_id + chapter.chapter_id = title_id + 1000 + chapter.name = "#001" + chapter.sub_title = "Sub" + return response.SerializeToString() + + +def _viewer_payload(chapter_id: int) -> bytes: + """Build a manga-viewer protobuf payload.""" + response = Response() + viewer = response.success.manga_viewer + viewer.title_id = 100010 + viewer.chapter_id = chapter_id + viewer.chapter_name = "#001" + page = viewer.pages.add() + page.manga_page.image_url = "https://img.example/page.webp" + last_page = viewer.pages.add() + last_page.last_page.current_chapter.title_id = 100010 + last_page.last_page.current_chapter.chapter_id = chapter_id + last_page.last_page.current_chapter.name = "#001" + return response.SerializeToString() + + +def test_gateway_fetches_caches_captures_and_evicts_payloads() -> None: + """Verify gateway transport, caching, capture, and eviction behavior.""" + session = DummySession() + capture = Capture() + client = gateway.MangaPlusGateway( + session=session, + api_base_url="https://api.example", + quality="low", + split=False, + payload_capture=capture, + auth_params_provider=lambda: {"secret": "token"}, + title_cache_max_size=1, + viewer_cache_max_size=1, + ) + + first_title = client.get_title_details(100010) + cached_title = client.get_title_details(100010) + second_title = client.get_title_details(100011) + first_viewer = client.load_pages(1000311) + cached_viewer = client.load_pages(1000311) + client.clear_title_caches(100010, None) + + assert first_title is cached_title + assert second_title.title.title_id == 100011 + assert first_viewer is cached_viewer + assert session.headers["User-Agent"] == "okhttp/4.12.0" + assert len(session.calls) == 3 + assert len(capture.records) == 3 + assert capture.records[0]["params"]["secret"] == "token" + + +def test_gateway_parse_manga_viewer_reports_payload_errors() -> None: + """Verify gateway viewer parser reports missing payload and identity errors.""" + missing_payload = Response() + missing_payload.success.title_detail_view.title.title_id = 100010 + missing_payload.success.title_detail_view.title.name = "Other" + + with pytest.raises(APIResponseError, match="no manga_viewer payload"): + parsing.parse_manga_viewer_response(missing_payload.SerializeToString()) + + missing_identity = Response() + missing_identity.success.manga_viewer.title_name = "Viewer without IDs" + + with pytest.raises(APIResponseError, match="without title/chapter IDs"): + parsing.parse_manga_viewer_response(missing_identity.SerializeToString()) + + +def test_gateway_parse_title_detail_reports_payload_errors() -> None: + """Verify gateway title parser reports missing payload and invalid title details.""" + missing_payload = Response() + missing_payload.success.manga_viewer.title_id = 100010 + missing_payload.success.manga_viewer.chapter_id = 1000311 + + with pytest.raises(APIResponseError, match="no title_detail_view payload"): + parsing.parse_title_detail_response(missing_payload.SerializeToString()) + + missing_identity = Response() + missing_identity.success.title_detail_view.overview = "missing identity" + missing_identity.success.title_detail_view.chapter_list_group.add().first_chapter_list.add() + + with pytest.raises(APIResponseError, match="without title identity"): + parsing.parse_title_detail_response(missing_identity.SerializeToString()) + + missing_groups = Response() + missing_groups.success.title_detail_view.title.title_id = 100010 + missing_groups.success.title_detail_view.title.name = "No groups" + + with pytest.raises(APIResponseError, match="without chapter groups"): + parsing.parse_title_detail_response(missing_groups.SerializeToString()) + + missing_entries = Response() + missing_entries.success.title_detail_view.title.title_id = 100010 + missing_entries.success.title_detail_view.title.name = "No entries" + missing_entries.success.title_detail_view.chapter_list_group.add() + + with pytest.raises(APIResponseError, match="without chapter entries"): + parsing.parse_title_detail_response(missing_entries.SerializeToString()) diff --git a/tests/test_mangaplus_mappers.py b/tests/test_mangaplus_mappers.py new file mode 100644 index 0000000..a8089da --- /dev/null +++ b/tests/test_mangaplus_mappers.py @@ -0,0 +1,143 @@ +"""Replay tests for MangaPlus protobuf-to-domain mappers.""" + +from __future__ import annotations + +from pathlib import Path + +from mloader.domain.planning import DownloadPlan, TitleDownloadPlan +from mloader.infrastructure.mangaplus import mappers +from mloader.infrastructure.mangaplus import parsing +from mloader.manga_loader.chapter_planning import ChapterPlanner +from mloader.response_pb2 import Response + +FIXTURE_CAPTURE_DIR = Path(__file__).parent / "fixtures" / "api_captures" / "baseline" + + +def test_title_detail_mapper_matches_fixture_chapter_planning() -> None: + """Verify title-detail DTOs preserve fixture data used by existing chapter planning.""" + raw_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + parsed = Response.FromString(raw_payload) + title_detail_proto = parsed.success.title_detail_view + mapped = parsing.parse_title_detail_response(raw_payload) + existing_chapter_data = ChapterPlanner.extract_chapter_data(mapped, lambda value: value) + + assert mapped.title.title_id == title_detail_proto.title.title_id == 100010 + assert mapped.title.name == title_detail_proto.title.name == "Dr. STONE" + assert len(mapped.chapters) == len(existing_chapter_data) == 236 + assert {chapter.chapter_id for chapter in mapped.chapters} == set(existing_chapter_data) + + +def test_title_detail_mapper_preserves_comicinfo_metadata() -> None: + """Verify title-detail metadata needed for ComicInfo is mapped onto titles.""" + response = Response() + title_detail = response.success.title_detail_view + title_detail.title.title_id = 100312 + title_detail.title.name = "Test" + title_detail.title.author = "Writer & Artist" + title_detail.overview = "Summary detail" + title_detail.sns.url = "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100312" + first_tag = title_detail.tags.add() + first_tag.name = "Action & Adventure" + first_tag.slug = "action-adventure" + second_tag = title_detail.tags.add() + second_tag.name = "Sci-Fi / Fantasy" + second_tag.slug = "sci-fi-fantasy" + chapter = title_detail.chapter_list.add() + chapter.title_id = 100312 + chapter.chapter_id = 1024959 + chapter.name = "#001" + + mapped = mappers.title_detail_from_proto(title_detail) + + assert mapped.overview == "Summary detail" + assert mapped.title.author == "Writer & Artist" + assert mapped.title.overview == "Summary detail" + assert ( + mapped.title.web_url == "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100312" + ) + assert [(tag.name, tag.slug) for tag in mapped.title.tags] == [ + ("Action & Adventure", "action-adventure"), + ("Sci-Fi / Fantasy", "sci-fi-fantasy"), + ] + + +def test_manga_viewer_mapper_matches_fixture_viewer_payload() -> None: + """Verify manga-viewer DTOs preserve fixture chapter and page identities.""" + raw_payload = (FIXTURE_CAPTURE_DIR / "0002_manga_viewer_1000311.pb").read_bytes() + parsed = Response.FromString(raw_payload) + viewer_proto = parsed.success.manga_viewer + mapped = parsing.parse_manga_viewer_response(raw_payload) + + assert mapped.title_id == viewer_proto.title_id == 100010 + assert mapped.chapter_id == viewer_proto.chapter_id == 1000311 + assert mapped.chapter_name == viewer_proto.chapter_name + assert [chapter.chapter_id for chapter in mapped.chapters] == [ + chapter.chapter_id for chapter in viewer_proto.chapters + ] + assert len(mapped.downloadable_pages) == len( + [page for page in viewer_proto.pages if page.manga_page.image_url] + ) + assert mapped.last_page is not None + assert mapped.last_page.current_chapter.chapter_id == viewer_proto.chapter_id + + +def test_mapper_handles_terminal_page_without_next_chapter() -> None: + """Verify mapper represents absent next-chapter and non-image pages explicitly.""" + response = Response() + viewer = response.success.manga_viewer + viewer.title_id = 100010 + viewer.chapter_id = 1000311 + viewer.title_name = "Dr. STONE" + viewer.chapter_name = "#001" + viewer_chapter = viewer.chapters.add() + viewer_chapter.title_id = 100010 + viewer_chapter.chapter_id = 1000311 + viewer_chapter.name = "#001" + viewer_page = viewer.pages.add() + viewer_page.last_page.current_chapter.title_id = 100010 + viewer_page.last_page.current_chapter.chapter_id = 1000311 + viewer_page.last_page.current_chapter.name = "#001" + + mapped = mappers.manga_viewer_from_proto(viewer) + + assert mapped.downloadable_pages == () + assert mapped.pages[0].manga_page is None + assert mapped.pages[0].last_page is not None + assert mapped.pages[0].last_page.next_chapter is None + + +def test_titles_from_all_titles_proto_flattens_title_groups() -> None: + """Verify title-index mapper returns stable title DTOs.""" + response = Response() + group = response.success.all_titles_view.title_groups.add() + first = group.titles.add() + first.title_id = 100001 + first.name = "First" + first.author = "Author A" + second = group.titles.add() + second.title_id = 100002 + second.name = "Second" + second.author = "Author B" + + titles = mappers.titles_from_all_titles_proto(response.success.all_titles_view) + + assert [title.title_id for title in titles] == [100001, 100002] + assert [title.name for title in titles] == ["First", "Second"] + + +def test_download_plan_can_be_built_from_replayed_fixture_selection() -> None: + """Verify fixture-derived selections can be represented as a stable domain plan.""" + raw_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + mapped = parsing.parse_title_detail_response(raw_payload) + selected_chapters = mapped.chapters[:2] + + plan = DownloadPlan( + title_plans=(TitleDownloadPlan(title_detail=mapped, selected_chapters=selected_chapters),) + ) + + assert plan.title_count == 1 + assert plan.chapter_count == 2 + assert plan.selections[0].title_id == 100010 + assert plan.selections[0].chapter_ids == frozenset( + chapter.chapter_id for chapter in selected_chapters + ) diff --git a/tests/test_mangaplus_parsing.py b/tests/test_mangaplus_parsing.py new file mode 100644 index 0000000..e4f9204 --- /dev/null +++ b/tests/test_mangaplus_parsing.py @@ -0,0 +1,228 @@ +"""Tests for MangaPlus protobuf payload parsing into domain DTOs.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import parsing +from mloader.response_pb2 import Response + + +def test_parse_manga_viewer_response() -> None: + """Verify viewer parser maps ``success.manga_viewer`` into a domain DTO.""" + parsed = Response() + viewer = parsed.success.manga_viewer + viewer.title_id = 100312 + viewer.chapter_id = 1024959 + viewer.title_name = "Test Title" + viewer.chapter_name = "#001" + page = viewer.pages.add() + page.manga_page.image_url = "https://img.example/page.webp" + page.manga_page.type = 1 + last_page = viewer.pages.add() + last_page.last_page.current_chapter.title_id = 100312 + last_page.last_page.current_chapter.chapter_id = 1024959 + last_page.last_page.current_chapter.name = "#001" + last_page.last_page.current_chapter.sub_title = "Sub" + + result = parsing.parse_manga_viewer_response(parsed.SerializeToString()) + + assert result.title_id == 100312 + assert result.chapter_id == 1024959 + assert result.downloadable_pages[0].image_url == "https://img.example/page.webp" + assert result.last_page is not None + assert result.last_page.current_chapter.sub_title == "Sub" + + +def test_parse_title_detail_response() -> None: + """Verify title-detail parser maps ``success.title_detail_view`` into a domain DTO.""" + parsed = Response() + title_detail = parsed.success.title_detail_view + title_detail.title.title_id = 100312 + title_detail.title.name = "Test" + title_detail.title.author = "Author" + title_detail.overview = "Summary & more" + title_detail.sns.url = "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100312" + tag = title_detail.tags.add() + tag.name = "Sci-Fi / Fantasy" + tag.slug = "sci-fi-fantasy" + group = title_detail.chapter_list_group.add() + chapter = group.first_chapter_list.add() + chapter.title_id = 100312 + chapter.chapter_id = 1024959 + chapter.name = "#001" + chapter.sub_title = "Sub" + + result = parsing.parse_title_detail_response(parsed.SerializeToString()) + + assert result.title.title_id == 100312 + assert result.title.name == "Test" + assert result.title.author == "Author" + assert result.title.overview == "Summary & more" + assert ( + result.title.web_url == "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100312" + ) + assert result.title.tags[0].name == "Sci-Fi / Fantasy" + assert result.title.tags[0].slug == "sci-fi-fantasy" + assert result.chapter_groups[0].first_chapters[0].chapter_id == 1024959 + + +def test_parse_title_detail_response_maps_flat_mobile_chapter_list() -> None: + """Verify mobile title details with flat chapters are normalized into a group.""" + parsed = Response() + title_detail = parsed.success.title_detail_view + title_detail.title.title_id = 100494 + title_detail.title.name = "Aliens, Baseball, and Civilization" + chapter = title_detail.chapter_list.add() + chapter.title_id = 100494 + chapter.chapter_id = 1024974 + chapter.name = "#001" + chapter.sub_title = "1st Pitch" + + result = parsing.parse_title_detail_response(parsed.SerializeToString()) + + assert len(result.chapter_groups) == 1 + assert result.chapter_groups[0].first_chapters[0].chapter_id == 1024974 + + +def test_parse_manga_viewer_response_raises_for_missing_payload() -> None: + """Verify viewer parser rejects payloads without ``success.manga_viewer``.""" + + class SuccessEnvelope: + def HasField(self, name: str) -> bool: + return name != "manga_viewer" + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SuccessEnvelope()) + + with pytest.raises(APIResponseError, match="no manga_viewer payload"): + parsing.parse_manga_viewer_response(b"raw", response_type=FakeResponse) + + +def test_raise_payload_error_classifies_empty_payload() -> None: + """Verify empty upstream payloads are reported distinctly.""" + with pytest.raises(APIResponseError, match="empty payload") as error: + parsing.raise_payload_error(b"", context="manga_viewer", payload_name="manga_viewer") + + assert error.value.kind == "empty" + + +def test_parse_title_detail_response_raises_for_missing_payload() -> None: + """Verify title parser rejects payloads without ``success.title_detail_view``.""" + + class SuccessEnvelope: + def HasField(self, name: str) -> bool: + return name != "title_detail_view" + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SuccessEnvelope()) + + with pytest.raises(APIResponseError, match="no title_detail_view payload"): + parsing.parse_title_detail_response(b"raw", response_type=FakeResponse) + + +def test_has_message_field_handles_non_protobuf_messages() -> None: + """Verify ``has_message_field`` returns true for objects without ``HasField``.""" + assert parsing.has_message_field(object(), "any") is True + + +def test_has_message_field_handles_invalid_field_name() -> None: + """Verify ``has_message_field`` returns false when protobuf rejects field name.""" + + class Message: + def HasField(self, _name: str) -> bool: + raise ValueError("unknown field") + + assert parsing.has_message_field(Message(), "missing") is False + + +def test_parse_manga_viewer_response_raises_for_missing_ids() -> None: + """Verify viewer parser rejects payloads missing title/chapter IDs.""" + viewer = SimpleNamespace(title_id=0, chapter_id=0, pages=[SimpleNamespace()]) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(manga_viewer=viewer)) + + with pytest.raises(APIResponseError, match="without title/chapter IDs"): + parsing.parse_manga_viewer_response(b"raw", response_type=FakeResponse) + + +def test_parse_manga_viewer_response_accepts_subscription_payload_without_pages() -> None: + """Verify viewer parser lets downloader handle subscription-required empty pages.""" + viewer = SimpleNamespace(title_id=1, chapter_id=2, pages=[]) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(manga_viewer=viewer)) + + result = parsing.parse_manga_viewer_response(b"raw", response_type=FakeResponse) + + assert result.title_id == 1 + assert result.chapter_id == 2 + assert result.pages == () + + +def test_parse_title_detail_response_raises_for_missing_title_identity() -> None: + """Verify title parser rejects payloads missing title identity fields.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=0, name=""), + chapter_list_group=[ + SimpleNamespace( + first_chapter_list=[SimpleNamespace()], + mid_chapter_list=[], + last_chapter_list=[], + ) + ], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + with pytest.raises(APIResponseError, match="without title identity"): + parsing.parse_title_detail_response(b"raw", response_type=FakeResponse) + + +def test_parse_title_detail_response_raises_for_missing_groups() -> None: + """Verify title parser rejects payloads with no chapter groups.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=1, name="T"), + chapter_list_group=[], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + with pytest.raises(APIResponseError, match="without chapter groups"): + parsing.parse_title_detail_response(b"raw", response_type=FakeResponse) + + +def test_parse_title_detail_response_raises_for_missing_entries() -> None: + """Verify title parser rejects groups that contain no chapters.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=1, name="T"), + chapter_list_group=[ + SimpleNamespace(first_chapter_list=[], mid_chapter_list=[], last_chapter_list=[]) + ], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + with pytest.raises(APIResponseError, match="without chapter entries"): + parsing.parse_title_detail_response(b"raw", response_type=FakeResponse) diff --git a/tests/test_mangaplus_settings.py b/tests/test_mangaplus_settings.py new file mode 100644 index 0000000..ae572a4 --- /dev/null +++ b/tests/test_mangaplus_settings.py @@ -0,0 +1,37 @@ +"""Tests for centralized MangaPlus infrastructure settings and auth helpers.""" + +from __future__ import annotations + +from mloader import config +from mloader.config import AuthSettings +from mloader.infrastructure.mangaplus import auth, settings + + +def test_api_url_joins_base_url_and_endpoint_path() -> None: + """Verify MangaPlus endpoint URLs are composed consistently.""" + assert ( + settings.api_url("https://jumpg-api.tokyo-cdn.com/", "/api/manga_viewer") + == "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" + ) + + +def test_default_title_index_endpoint_uses_central_api_base() -> None: + """Verify title-index endpoint is derived from central API settings.""" + assert settings.DEFAULT_TITLE_INDEX_ENDPOINT == settings.api_url( + settings.DEFAULT_API_BASE_URL, + settings.TITLE_INDEX_PATH, + ) + + +def test_auth_params_returns_configured_settings_mapping() -> None: + """Verify MangaPlus auth helper renders settings as query params.""" + custom = AuthSettings(app_ver="1", os="android", os_ver="14", secret="secret") + + assert auth.auth_params(custom) == { + "app_ver": "1", + "os": "android", + "os_ver": "14", + "secret": "secret", + } + assert auth.auth_params() == config.AUTH_SETTINGS.as_query_params() + assert set(auth.auth_params()) == {"app_ver", "os", "os_ver", "secret"} diff --git a/tests/test_mangaplus_static_discovery.py b/tests/test_mangaplus_static_discovery.py new file mode 100644 index 0000000..d650210 --- /dev/null +++ b/tests/test_mangaplus_static_discovery.py @@ -0,0 +1,46 @@ +"""Tests for static MangaPlus list-page discovery.""" + +from __future__ import annotations + +import pytest + +from mloader.infrastructure.mangaplus import static_discovery +from tests.http_fakes import TextMappingSession + + +def test_extract_title_ids_respects_id_length_filter() -> None: + """Verify HTML extraction keeps only IDs matching configured digit length.""" + html = ( + 'ok' + 'short' + 'dup' + ) + assert static_discovery.extract_title_ids(html, id_length=6) == {123456} + assert static_discovery.extract_title_ids(html, id_length=None) == {10031, 123456} + + +def test_extract_title_ids_matches_escaped_slash_links() -> None: + """Verify extractor supports escaped JSON-style title links.""" + html = r'{"href":"\/titles\/123456\/"}{"href":"\/titles\/654321"}' + assert static_discovery.extract_title_ids(html, id_length=None) == {123456, 654321} + + +def test_collect_title_ids_returns_sorted_unique_ids(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify static scraper deduplicates IDs and returns sorted list.""" + payloads = { + "https://a.example": 'AB', + "https://b.example": 'CD', + } + dummy_session = TextMappingSession(payloads) + monkeypatch.setattr(static_discovery.requests, "Session", lambda: dummy_session) + + result = static_discovery.collect_title_ids( + ["https://a.example", "https://b.example"], + id_length=6, + ) + + assert result == [100001, 100002, 100003] + assert dummy_session.calls == [ + ("https://a.example", (5.0, 30.0)), + ("https://b.example", (5.0, 30.0)), + ] diff --git a/tests/test_mangaplus_title_discovery_gateway.py b/tests/test_mangaplus_title_discovery_gateway.py new file mode 100644 index 0000000..6c477cd --- /dev/null +++ b/tests/test_mangaplus_title_discovery_gateway.py @@ -0,0 +1,95 @@ +"""Tests for the MangaPlus title-discovery gateway adapter.""" + +from __future__ import annotations + +import pytest + +from mloader.infrastructure.mangaplus import title_discovery + + +def test_gateway_delegates_to_title_discovery_helpers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify gateway methods delegate to module helpers with expected arguments.""" + gateway = title_discovery.MangaPlusTitleDiscoveryGateway() + calls: dict[str, object] = {} + + def _parse_language_filters(languages: tuple[str, ...]) -> set[int]: + calls["languages"] = languages + return {7} + + def _collect_title_ids_from_api( + endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float], + capture_api_dir: str | None, + ) -> list[int]: + calls["api"] = ( + endpoint, + id_length, + allowed_languages, + request_timeout, + capture_api_dir, + ) + return [100001] + + def _collect_title_ids( + pages: tuple[str, ...], + *, + id_length: int | None, + request_timeout: tuple[float, float], + ) -> list[int]: + calls["static"] = (pages, id_length, request_timeout) + return [100002] + + def _collect_title_ids_with_browser( + pages: tuple[str, ...], + *, + id_length: int | None, + timeout_ms: int, + ) -> list[int]: + calls["browser"] = (pages, id_length, timeout_ms) + return [100003] + + monkeypatch.setattr( + title_discovery.title_index, "parse_language_filters", _parse_language_filters + ) + monkeypatch.setattr( + title_discovery.title_index, + "collect_title_ids_from_api", + _collect_title_ids_from_api, + ) + monkeypatch.setattr(title_discovery.static_discovery, "collect_title_ids", _collect_title_ids) + monkeypatch.setattr( + title_discovery.browser_discovery, + "collect_title_ids_with_browser", + _collect_title_ids_with_browser, + ) + + assert gateway.parse_language_filters(("german",)) == {7} + assert gateway.collect_title_ids_from_api( + "https://example.com/allV2", + id_length=6, + allowed_languages={7}, + request_timeout=(1.0, 2.0), + capture_api_dir="/tmp/capture", + ) == [100001] + assert gateway.collect_title_ids( + ("https://example.com/list",), + id_length=None, + request_timeout=(3.0, 4.0), + ) == [100002] + assert gateway.collect_title_ids_with_browser( + ("https://example.com/list",), + id_length=5, + timeout_ms=123, + ) == [100003] + + assert calls == { + "languages": ("german",), + "api": ("https://example.com/allV2", 6, {7}, (1.0, 2.0), "/tmp/capture"), + "static": (("https://example.com/list",), None, (3.0, 4.0)), + "browser": (("https://example.com/list",), 5, 123), + } diff --git a/tests/test_mangaplus_title_index.py b/tests/test_mangaplus_title_index.py new file mode 100644 index 0000000..35a5b2d --- /dev/null +++ b/tests/test_mangaplus_title_index.py @@ -0,0 +1,271 @@ +"""Tests for MangaPlus title-index API discovery.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import requests + +from mloader.errors import APIResponseError +from mloader.infrastructure.mangaplus import auth, title_index +from mloader.response_pb2 import Response +from tests.http_fakes import BytesMappingSession, BytesResponse + + +def _build_all_titles_payload(title_ids: list[int]) -> bytes: + """Build a minimal serialized all-titles protobuf payload for tests.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + group.group_name = "group" + for title_id in title_ids: + title = group.titles.add() + title.title_id = title_id + title.name = f"title-{title_id}" + return parsed.SerializeToString() + + +def _build_all_titles_payload_with_languages(titles: list[tuple[int, int]]) -> bytes: + """Build a minimal serialized all-titles protobuf payload with language codes.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + group.group_name = "group" + for title_id, language in titles: + title = group.titles.add() + title.title_id = title_id + title.name = f"title-{title_id}" + title.language = language + return parsed.SerializeToString() + + +def test_extract_title_ids_from_api_payload_respects_id_length_filter() -> None: + """Verify binary API extraction keeps IDs matching configured digit length.""" + payload = _build_all_titles_payload([100001, 99999]) + assert title_index.extract_title_ids_from_api_payload(payload, id_length=6) == {100001} + assert title_index.extract_title_ids_from_api_payload(payload, id_length=None) == { + 99999, + 100001, + } + + +def test_extract_title_ids_from_api_payload_skips_non_positive_ids() -> None: + """Verify protobuf extraction ignores non-positive title IDs.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + title = group.titles.add() + title.title_id = 0 + + assert ( + title_index.extract_title_ids_from_api_payload( + parsed.SerializeToString(), + id_length=None, + ) + == set() + ) + + +def test_extract_title_ids_from_api_payload_filters_languages() -> None: + """Verify protobuf extraction can filter by allowed language codes.""" + payload = _build_all_titles_payload_with_languages( + [ + (100001, 0), + (100002, 1), + (100003, 0), + ] + ) + + result = title_index.extract_title_ids_from_api_payload_with_language_filter( + payload, + id_length=6, + allowed_languages={0}, + ) + + assert result == {100001, 100003} + + +def test_extract_title_ids_from_api_payload_rejects_unknown_payload() -> None: + """Verify title-index parsing reports schema drift for undecodable payloads.""" + with pytest.raises(APIResponseError, match="schema drift") as error: + title_index.extract_title_ids_from_api_payload(b"not-protobuf") + + assert error.value.kind == "unknown" + + +def test_extract_title_ids_from_api_payload_rejects_success_without_title_index() -> None: + """Verify success envelopes without all_titles_view are reported as schema drift.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100001 + parsed.success.title_detail_view.title.name = "Other payload" + + with pytest.raises(APIResponseError, match="without all_titles_view") as error: + title_index.extract_title_ids_from_api_payload( + parsed.SerializeToString(), + id_length=None, + ) + + assert error.value.kind == "unknown" + + +def test_collect_title_ids_from_api_returns_sorted_unique_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify API scraper deduplicates IDs and returns sorted list.""" + payload = _build_all_titles_payload([100003, 100001, 100001, 100002]) + dummy_session = BytesMappingSession({"https://api.example/allV2": payload}) + monkeypatch.setattr(title_index.requests, "Session", lambda: dummy_session) + + result = title_index.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages=None, + ) + + assert result == [100001, 100002, 100003] + assert dummy_session.calls == [("https://api.example/allV2", auth.auth_params(), (5.0, 30.0))] + assert dummy_session.headers["User-Agent"] == "okhttp/4.12.0" + assert "Host" not in dummy_session.headers + + +def test_collect_title_ids_from_api_captures_title_index_payload( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify capture mode stores title-index payloads during --all discovery.""" + payload = _build_all_titles_payload([100001]) + dummy_session = BytesMappingSession({"https://api.example/allV2": payload}) + monkeypatch.setattr(title_index.requests, "Session", lambda: dummy_session) + + result = title_index.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages={0}, + capture_api_dir=str(tmp_path), + ) + + assert result == [100001] + metadata = json.loads(next(tmp_path.glob("*.meta.json")).read_text(encoding="utf-8")) + assert metadata["endpoint"] == "title_index" + assert metadata["payload_classification"] == "success" + assert metadata["params"]["allowed_languages"] == [0] + + +def test_collect_title_ids_from_api_retries_transient_http_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify API scraper retries temporary upstream errors before succeeding.""" + payload = _build_all_titles_payload([100002, 100001]) + + class FlakySession: + """Session test double failing once with HTTP 502 then succeeding.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str] | None, tuple[float, float]]] = [] + self._attempt = 0 + self.headers: dict[str, str] = {} + + def __enter__(self) -> FlakySession: + return self + + def __exit__(self, *args: object) -> None: + _ = args + + def get( + self, + url: str, + params: dict[str, str] | None = None, + timeout: tuple[float, float] = (5.0, 30.0), + ) -> BytesResponse: + self.calls.append((url, params, timeout)) + self._attempt += 1 + if self._attempt == 1: + return BytesResponse(status_code=502) + return BytesResponse(content=payload) + + flaky_session = FlakySession() + monkeypatch.setattr(title_index.requests, "Session", lambda: flaky_session) + monkeypatch.setattr(title_index.time, "sleep", lambda _seconds: None) + + result = title_index.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages=None, + ) + + assert result == [100001, 100002] + assert len(flaky_session.calls) == 2 + + +def test_collect_title_ids_from_api_does_not_retry_non_transient_http_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify non-transient HTTP errors surface immediately.""" + dummy_session = BytesMappingSession( + {"https://api.example/allV2": BytesResponse(status_code=404)} + ) + monkeypatch.setattr(title_index.requests, "Session", lambda: dummy_session) + + with pytest.raises(requests.HTTPError, match="404 error"): + title_index.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages=None, + ) + + +def test_collect_title_ids_from_api_retries_request_errors_until_exhausted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify repeated network errors are retried and then surfaced.""" + + class FailingSession: + """Session test double raising request errors on every attempt.""" + + def __init__(self) -> None: + self.calls = 0 + self.headers: dict[str, str] = {} + + def __enter__(self) -> FailingSession: + return self + + def __exit__(self, *args: object) -> None: + _ = args + + def get( + self, + _url: str, + params: dict[str, str] | None = None, + timeout: tuple[float, float] = (5.0, 30.0), + ) -> BytesResponse: + del params + del timeout + self.calls += 1 + raise requests.RequestException("network down") + + failing_session = FailingSession() + monkeypatch.setattr(title_index.requests, "Session", lambda: failing_session) + monkeypatch.setattr(title_index.time, "sleep", lambda _seconds: None) + + with pytest.raises(requests.RequestException, match="network down"): + title_index.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages=None, + ) + + assert failing_session.calls == title_index.API_MAX_ATTEMPTS + + +def test_parse_language_filters_returns_none_for_empty_input() -> None: + """Verify empty language filters preserve unfiltered behavior.""" + assert title_index.parse_language_filters(()) is None + + +def test_parse_language_filters_merges_multiple_languages() -> None: + """Verify language filter parser resolves multiple language selectors.""" + result = title_index.parse_language_filters(("english", "vietnamese")) + + assert result is not None + assert 0 in result + assert 9 in result + assert 8 in result diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..c2a1f9f --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,291 @@ +"""Tests for persistent title download manifest behavior.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +import pytest + +from mloader.manga_loader.manifest import ( + MANIFEST_FILENAME, + MANIFEST_SCHEMA, + MANIFEST_VERSION, + TitleDownloadManifest, +) + + +def _load_manifest(path: Path) -> dict[str, Any]: + """Read manifest JSON payload from ``path``.""" + return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + + +def test_manifest_tracks_started_completed_and_failed_states(tmp_path: Path) -> None: + """Verify manifest writes state transitions and persists across reloads.""" + manifest = TitleDownloadManifest(tmp_path) + manifest_path = tmp_path / MANIFEST_FILENAME + + manifest.mark_started(1, chapter_name="#1", sub_title="Start", output_format="pdf") + payload_started = _load_manifest(manifest_path) + chapter_started = payload_started["chapters"]["1"] + assert chapter_started["status"] == "in_progress" + assert chapter_started["output_format"] == "pdf" + + manifest.mark_completed(1, output_path="/tmp/out.pdf") + manifest_reloaded = TitleDownloadManifest(tmp_path) + payload_completed = _load_manifest(manifest_path) + chapter_completed = payload_completed["chapters"]["1"] + assert chapter_completed["status"] == "completed" + assert chapter_completed["output_path"] == "/tmp/out.pdf" + assert manifest_reloaded.is_completed(1) is True + + manifest_reloaded.mark_failed(2, error="boom") + payload_failed = _load_manifest(manifest_path) + chapter_failed = payload_failed["chapters"]["2"] + assert chapter_failed["status"] == "failed" + assert chapter_failed["error"] == "boom" + + +def test_manifest_load_migrates_v1_payload_and_persists_current_schema(tmp_path: Path) -> None: + """Verify versioned payloads are migrated to latest schema on load.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": 1, + "chapters": { + "3": { + "chapter_id": 3, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(3) is True + migrated_payload = _load_manifest(manifest_path) + assert migrated_payload["version"] == MANIFEST_VERSION + assert migrated_payload["schema"] == MANIFEST_SCHEMA + + +def test_manifest_load_migrates_unversioned_payload_shape(tmp_path: Path) -> None: + """Verify old unversioned chapter-map payloads are migrated and preserved.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "7": { + "chapter_id": 7, + "status": "completed", + } + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(7) is True + migrated_payload = _load_manifest(manifest_path) + assert migrated_payload["version"] == MANIFEST_VERSION + assert migrated_payload["schema"] == MANIFEST_SCHEMA + assert migrated_payload["chapters"]["7"]["status"] == "completed" + + +def test_manifest_load_accepts_future_version_without_migration(tmp_path: Path) -> None: + """Verify newer unknown manifest versions still load chapter completion state.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": MANIFEST_VERSION + 1, + "chapters": { + "9": { + "chapter_id": 9, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(9) is True + + +def test_manifest_load_returns_empty_when_migration_step_is_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify missing migration mapping fails closed with empty in-memory chapter state.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": 0, + "chapters": { + "1": { + "chapter_id": 1, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("mloader.manga_loader.manifest.MANIFEST_MIGRATIONS", {}) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(1) is False + + +@pytest.mark.parametrize( + "content", + [ + "not-json", + "[]", + "{}", + '{"chapters": []}', + ], +) +def test_manifest_load_handles_invalid_or_unexpected_payloads( + tmp_path: Path, + content: str, +) -> None: + """Verify malformed manifest payloads are ignored without raising.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text(content, encoding="utf-8") + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(1) is False + + manifest.mark_started(1, chapter_name="#1", sub_title="Sub", output_format="cbz") + payload = _load_manifest(manifest_path) + assert payload["chapters"]["1"]["status"] == "in_progress" + + +def test_manifest_autosave_disabled_requires_flush(tmp_path: Path) -> None: + """Verify autosave-disabled manifests persist changes only on explicit flush.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest_path = tmp_path / MANIFEST_FILENAME + + manifest.mark_started(1, chapter_name="#1", sub_title="Sub", output_format="cbz") + assert manifest_path.exists() is False + + manifest.mark_completed(1, output_path="/tmp/out.cbz") + assert manifest_path.exists() is False + + manifest.flush() + payload = _load_manifest(manifest_path) + assert payload["chapters"]["1"]["status"] == "completed" + assert payload["chapters"]["1"]["output_path"] == "/tmp/out.cbz" + + +def test_manifest_reset_clears_manifest_file(tmp_path: Path) -> None: + """Verify reset removes persisted manifest state.""" + manifest = TitleDownloadManifest(tmp_path) + manifest_path = tmp_path / MANIFEST_FILENAME + manifest.mark_completed(1) + assert manifest_path.exists() is True + + manifest.reset() + + assert manifest_path.exists() is False + assert manifest.is_completed(1) is False + + +def test_manifest_autosave_merges_updates_across_instances(tmp_path: Path) -> None: + """Verify separate instances can append chapter state without dropping previous entries.""" + manifest_a = TitleDownloadManifest(tmp_path) + manifest_b = TitleDownloadManifest(tmp_path) + + manifest_a.mark_completed(1, output_path="/tmp/one.cbz") + manifest_b.mark_failed(2, error="boom") + + payload = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert set(payload["chapters"].keys()) == {"1", "2"} + + +def test_manifest_save_persists_pending_changes(tmp_path: Path) -> None: + """Verify explicit save persists in-memory changes for autosave-disabled mode.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest.mark_failed(7, error="boom") + + manifest.save() + + payload = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert payload["chapters"]["7"]["status"] == "failed" + + +def test_manifest_flush_returns_when_dirty_clears_during_lock( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify flush exits when dirty state is cleared before locked write branch.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest._dirty = True + + class Lock: + def __enter__(self) -> None: + manifest._dirty = False + return None + + def __exit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr(manifest, "_lock", Lock()) + manifest.flush() + assert manifest._dirty is False + + +def test_manifest_mark_entry_noops_when_update_is_identical_autosave_true( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify autosave mode skips writes when chapter entry does not change.""" + monkeypatch.setattr( + "mloader.manga_loader.manifest._utc_timestamp", lambda: "2026-02-24T00:00:00Z" + ) + manifest = TitleDownloadManifest(tmp_path) + manifest.mark_failed(1, error="boom") + payload_before = _load_manifest(tmp_path / MANIFEST_FILENAME) + + manifest.mark_failed(1, error="boom") + + payload_after = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert payload_before == payload_after + + +def test_manifest_mark_entry_noops_when_update_is_identical_autosave_false( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify autosave-disabled mode skips dirtying when chapter entry is unchanged.""" + monkeypatch.setattr( + "mloader.manga_loader.manifest._utc_timestamp", lambda: "2026-02-24T00:00:00Z" + ) + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest.mark_failed(2, error="boom") + manifest._dirty = False + + manifest.mark_failed(2, error="boom") + + assert manifest._dirty is False + + +def test_manifest_reset_is_noop_when_file_is_missing(tmp_path: Path) -> None: + """Verify reset does not fail when manifest file does not exist.""" + manifest = TitleDownloadManifest(tmp_path) + + manifest.reset() + + assert (tmp_path / MANIFEST_FILENAME).exists() is False diff --git a/tests/test_public_app_surface.py b/tests/test_public_app_surface.py new file mode 100644 index 0000000..d83bd9b --- /dev/null +++ b/tests/test_public_app_surface.py @@ -0,0 +1,26 @@ +"""Tests for the current public app surface.""" + +from __future__ import annotations + +import importlib + +from mloader.exporters import CBZExporter, ExporterBase, PDFExporter, RawExporter +from mloader.manga_loader.init import MangaLoader + + +def test_package_and_entrypoint_are_current_public_surface() -> None: + """Verify package and command-entry imports are part of the current surface.""" + package = importlib.import_module("mloader") + entrypoint = importlib.import_module("mloader.__main__") + + assert package.__doc__ == "Top-level package for mloader." + assert callable(entrypoint.main) + + +def test_runtime_and_exporters_are_current_public_surface() -> None: + """Verify runtime facade and exporter imports are part of the current surface.""" + assert MangaLoader.__name__ == "MangaLoader" + assert ExporterBase.__name__ == "ExporterBase" + assert RawExporter.format == "raw" + assert CBZExporter.format == "cbz" + assert PDFExporter.format == "pdf" diff --git a/tests/test_readme_cli_options.py b/tests/test_readme_cli_options.py new file mode 100644 index 0000000..5af69b1 --- /dev/null +++ b/tests/test_readme_cli_options.py @@ -0,0 +1,47 @@ +"""Tests ensuring README CLI option docs stay in sync with command options.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import click + +from mloader.cli.main import main as cli_main +from mloader.cli.readme_reference import replace_readme_cli_reference + + +def _extract_option_names_from_help(help_text: str) -> set[str]: + """Extract long option names from Click help output.""" + option_names: set[str] = set() + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + matches = re.findall(r"--[a-zA-Z0-9-]+", stripped) + option_names.update(matches) + return option_names + + +def test_readme_mentions_every_cli_long_option() -> None: + """Verify README command docs mention all currently supported long options.""" + readme_text = Path("README.md").read_text(encoding="utf-8") + context = click.Context(cli_main) + help_text = cli_main.get_help(context) + option_names = _extract_option_names_from_help(help_text) + + # Keep only stable user-facing options from click help extraction. + ignored = {"--help", "--version"} + required_options = sorted(option_names - ignored) + + for option_name in required_options: + assert option_name in readme_text, ( + f"README is missing option documentation for {option_name}" + ) + + +def test_readme_cli_reference_block_is_synced() -> None: + """Verify README auto-generated CLI reference block is synchronized.""" + readme_text = Path("README.md").read_text(encoding="utf-8") + rendered = replace_readme_cli_reference(readme_text, command=cli_main) + assert rendered == readme_text diff --git a/tests/test_runtime_chapter_download.py b/tests/test_runtime_chapter_download.py new file mode 100644 index 0000000..d135e23 --- /dev/null +++ b/tests/test_runtime_chapter_download.py @@ -0,0 +1,297 @@ +"""Tests for chapter-level download orchestration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from mloader.domain.manga import MangaPage +from mloader.domain.manga import TitleTag +from mloader.errors import SubscriptionRequiredError +from mloader.manga_loader.chapter_download import ChapterDownloader +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.types import ChapterLike, ExporterLike, PageIndex, TitleLike +from tests.downloader_helpers import ( + NullExporterFactory, + chapter as _chapter, + manga_page as _manga_page, + title_detail as _title_detail, + viewer as _viewer, +) + + +class NoopManifest: + """Manifest double implementing the runtime manifest protocol.""" + + def reset(self) -> None: + """No-op reset.""" + + def flush(self) -> None: + """No-op flush.""" + + def is_completed(self, chapter_id: int) -> bool: + """Treat every chapter as incomplete.""" + del chapter_id + return False + + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + """No-op start marker.""" + del chapter_id, chapter_name, sub_title, output_format + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + """No-op completion marker.""" + del chapter_id, output_path + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + """No-op failure marker.""" + del chapter_id, error + + +class FixedExporterFactory: + """Exporter factory double returning one prebuilt exporter.""" + + def __init__(self, exporter: ExporterLike) -> None: + """Store the exporter instance returned for each factory call.""" + self.exporter = exporter + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + """Return the configured exporter instance.""" + del title, chapter, next_chapter + return self.exporter + + +def test_process_chapter_raises_when_subscription_required() -> None: + """Verify chapter downloader raises subscription error without last_page payload.""" + viewer = _viewer(chapter_name="C1", include_last_page=False) + + with pytest.raises(SubscriptionRequiredError): + ChapterDownloader.process_chapter( + viewer=viewer, + title=_title_detail(name="t").title, + chapter_index=1, + total_chapters=1, + chapter_id=10, + output_format="pdf", + manifest=None, + exporter_factory=NullExporterFactory("unused"), + process_pages=lambda *_args: None, + prepare_filename=FilenamePolicy.prepare_filename, + ) + + +def test_process_chapter_creates_exporter_and_closes() -> None: + """Verify chapter downloader builds exporter, processes pages, and closes exporter.""" + + class ExporterInstance: + """Exporter test double with close tracking.""" + + def __init__(self) -> None: + """Initialize close tracking state.""" + self.closed = False + + def close(self) -> None: + """Record close invocation.""" + self.closed = True + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Accept image writes without side effects.""" + del image_data, index + + def skip_image(self, index: PageIndex) -> bool: + """Return false so processing continues.""" + del index + return False + + instance = ExporterInstance() + captured: dict[str, Any] = {} + started: list[tuple[int, str, str, str]] = [] + completed: list[tuple[int, str | None]] = [] + + class Manifest(NoopManifest): + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + started.append((chapter_id, chapter_name, sub_title, output_format)) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + completed.append((chapter_id, output_path)) + + class ExporterFactory: + """Capture exporter constructor arguments and return test instance.""" + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None = None, + ) -> ExporterLike: + captured.update({"title": title, "chapter": chapter, "next_chapter": next_chapter}) + return instance + + processed: list[tuple[tuple[MangaPage, ...], str, ExporterLike]] = [] + current_chapter = _chapter(10, "#1", "Sub/Raw", start_timestamp=1747407600) + viewer = _viewer( + chapter_id=10, + chapter_name="#1", + current_chapter=current_chapter, + pages=(_manga_page("u1"),), + ) + + title_detail = _title_detail( + name="My Manga", + overview="Summary", + tags=(TitleTag(name="Action", slug="action"),), + web_url="https://example.invalid/title", + ) + ChapterDownloader.process_chapter( + viewer=viewer, + title=title_detail.title, + chapter_index=1, + total_chapters=1, + chapter_id=10, + output_format="pdf", + manifest=Manifest(), + exporter_factory=ExporterFactory(), + process_pages=lambda pages, chapter_name, exporter: processed.append( + (pages, chapter_name, exporter) + ), + prepare_filename=FilenamePolicy.prepare_filename, + ) + + assert captured["title"] is title_detail.title + assert captured["title"].overview == "Summary" + assert captured["title"].tags[0].name == "Action" + assert captured["title"].web_url == "https://example.invalid/title" + assert captured["chapter"].sub_title == "Sub Raw" + assert captured["chapter"].start_timestamp == 1747407600 + assert current_chapter.sub_title == "Sub/Raw" + assert captured["next_chapter"] is None + assert processed[0][1] == "#1" + assert len(processed[0][0]) == 1 + assert instance.closed is True + assert started == [(10, "#1", "Sub Raw", "pdf")] + assert completed == [(10, None)] + + +def test_process_chapter_marks_manifest_failed_when_page_processing_raises() -> None: + """Verify chapter export-processing failures are raised to title-level handling.""" + discarded: list[bool] = [] + + class Exporter: + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Accept image writes without side effects.""" + del image_data, index + + def skip_image(self, index: PageIndex) -> bool: + """Return false so processing continues.""" + del index + return False + + def close(self) -> None: + """No-op close used by this failure-path test.""" + + def discard(self) -> None: + """Record cleanup when export processing fails.""" + discarded.append(True) + + viewer = _viewer( + chapter_id=10, + chapter_name="#1", + current_chapter=_chapter(10, "#1", "Sub"), + pages=(_manga_page("u1"),), + ) + + with pytest.raises(RuntimeError, match="boom"): + ChapterDownloader.process_chapter( + viewer=viewer, + title=_title_detail(name="My Manga").title, + chapter_index=1, + total_chapters=1, + chapter_id=10, + output_format="pdf", + manifest=NoopManifest(), + exporter_factory=FixedExporterFactory(Exporter()), + process_pages=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), + prepare_filename=FilenamePolicy.prepare_filename, + ) + + assert discarded == [True] + + +def test_process_chapter_raises_when_no_downloadable_pages() -> None: + """Verify chapter download fails when viewer payload has no downloadable image pages.""" + started: list[int] = [] + completed: list[int] = [] + + class Exporter: + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Accept image writes without side effects.""" + del image_data, index + + def skip_image(self, index: PageIndex) -> bool: + """Return false so processing continues.""" + del index + return False + + def close(self) -> None: + """No-op close for no-pages path.""" + + class Manifest(NoopManifest): + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + del chapter_name, sub_title, output_format + started.append(chapter_id) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + del output_path + completed.append(chapter_id) + + viewer = _viewer( + chapter_id=10, + chapter_name="#1", + current_chapter=_chapter(10, "#1", "Sub"), + pages=(), + ) + + with pytest.raises(RuntimeError, match="no downloadable pages"): + ChapterDownloader.process_chapter( + viewer=viewer, + title=_title_detail(name="My Manga").title, + chapter_index=1, + total_chapters=1, + chapter_id=10, + output_format="pdf", + manifest=Manifest(), + exporter_factory=FixedExporterFactory(Exporter()), + process_pages=lambda *_args: None, + prepare_filename=FilenamePolicy.prepare_filename, + ) + + assert started == [10] + assert completed == [] diff --git a/tests/test_runtime_chapter_planning.py b/tests/test_runtime_chapter_planning.py new file mode 100644 index 0000000..1987b86 --- /dev/null +++ b/tests/test_runtime_chapter_planning.py @@ -0,0 +1,216 @@ +"""Tests for runtime chapter planning and filename decisions.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import Any + +from mloader.domain.planning import title_detail_with_selected_chapters +from mloader.manga_loader.chapter_planning import ChapterMetadata, ChapterPlanner, DownloadPlanner +from mloader.manga_loader.filename_policy import FilenamePolicy +from tests.downloader_helpers import ( + chapter as _chapter, + title_detail as _title_detail, +) + + +def test_filter_chapters_to_download_skips_existing_files() -> None: + """Verify existing chapter files are skipped from download candidates.""" + chapter_data = { + 1024959: ChapterMetadata( + thumbnail_url="", + chapter_id=1024959, + sub_title="Chapter One", + ), + 102278: ChapterMetadata( + thumbnail_url="", + chapter_id=102278, + sub_title="Chapter Two", + ), + } + chapter1 = _chapter(1024959, "#1024959") + chapter2 = _chapter(102278, "#102278") + title_detail = _title_detail(chapters=[chapter1, chapter2]) + + existing = [ChapterPlanner.build_expected_filename("My Manga", chapter1, "Chapter One")] + + result = DownloadPlanner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files=existing, + requested_chapter_ids={1024959, 102278}, + ) + + assert result == [102278] + + +def test_filter_chapters_to_download_skips_existing_files_using_new_style() -> None: + """Verify new naming style uses language-tagged expected stems for skip checks.""" + chapter_data = { + 1024959: ChapterMetadata( + thumbnail_url="", + chapter_id=1024959, + sub_title="Chapter One", + ), + } + chapter = _chapter(1024959, "#1024959", "Chapter One") + base_title_detail = _title_detail(chapters=[chapter]) + title_detail = replace( + base_title_detail, + title=replace(base_title_detail.title, language=8), + ) + # A legacy-style file should not match new-style filtering and therefore should download. + existing = [ + ChapterPlanner.build_expected_filename_with_style( + "My Manga", chapter, "Chapter One", 8, filename_style="legacy" + ) + ] + + result = DownloadPlanner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files=existing, + requested_chapter_ids={1024959}, + filename_style="new", + ) + + assert result == [1024959] + + +def test_chapter_output_extension_delegates_to_download_planner() -> None: + """Verify chapter-level extensions are derived by output format.""" + assert DownloadPlanner.chapter_output_extension("raw") is None + assert DownloadPlanner.chapter_output_extension("pdf") == "pdf" + + +def test_extract_chapter_data_from_all_groups() -> None: + """Verify chapter metadata extraction includes first/mid/last chapter groups.""" + title_detail = _title_detail( + chapters=[ + _chapter(1024959, "#1", "A", thumbnail_url="t1"), + _chapter(102278, "#2", "B", thumbnail_url="t2"), + _chapter(102279, "#3", "C", thumbnail_url="t3"), + ] + ) + + result = ChapterPlanner.extract_chapter_data(title_detail, FilenamePolicy.prepare_filename) + + assert result[1024959].chapter_id == 1024959 + assert result[102278].chapter_id == 102278 + assert result[102279].chapter_id == 102279 + assert result[102278].sub_title == "B" + + +def test_extract_chapter_data_keeps_duplicate_subtitles_by_chapter_id() -> None: + """Verify duplicate subtitles do not overwrite chapter metadata entries.""" + title_detail = _title_detail( + chapters=[ + _chapter(1024959, "#1", "Same", thumbnail_url="t1"), + _chapter(102278, "#2", "Same", thumbnail_url="t2"), + ] + ) + + result = ChapterPlanner.extract_chapter_data(title_detail, FilenamePolicy.prepare_filename) + + assert set(result.keys()) == {1024959, 102278} + assert result[1024959].sub_title == "Same" + assert result[102278].sub_title == "Same" + + +def test_get_existing_files_returns_stems(tmp_path: Path) -> None: + """Verify existing-file lookup returns PDF stems only.""" + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.pdf").write_bytes(b"1") + (export_path / "b.pdf").write_bytes(b"2") + (export_path / "c.cbz").write_bytes(b"3") + + assert sorted(DownloadPlanner.get_existing_files(export_path, output_format="pdf")) == [ + "a", + "b", + ] + + +def test_get_existing_files_returns_empty_when_missing(tmp_path: Path) -> None: + """Verify existing-file lookup returns empty list for missing export path.""" + assert DownloadPlanner.get_existing_files(tmp_path / "missing", output_format="pdf") == [] + + +def test_get_existing_files_uses_cbz_extension(tmp_path: Path) -> None: + """Verify existing chapter lookup uses cbz extension in CBZ mode.""" + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.cbz").write_bytes(b"1") + (export_path / "b.pdf").write_bytes(b"2") + + assert DownloadPlanner.get_existing_files(export_path, output_format="cbz") == ["a"] + + +def test_get_existing_files_is_disabled_for_raw_mode(tmp_path: Path) -> None: + """Verify raw mode disables chapter-level existing-file prefiltering.""" + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.pdf").write_bytes(b"1") + + assert DownloadPlanner.get_existing_files(export_path, output_format="raw") == [] + + +def test_filter_chapters_warns_when_chapter_missing(caplog: Any) -> None: + """Verify missing chapter IDs log a warning and are excluded.""" + chapter_data = {99: ChapterMetadata(thumbnail_url="", chapter_id=99, sub_title="Missing")} + title_detail = _title_detail(chapters=[]) + + with caplog.at_level("WARNING"): + result = DownloadPlanner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files=[], + requested_chapter_ids={102399}, + ) + + assert result == [] + assert "not found in title dump" in caplog.text + + +def test_filter_chapters_accepts_metadata_values() -> None: + """Verify chapter filtering accepts canonical ``ChapterMetadata`` objects.""" + chapter = _chapter(102305, "#102305") + title_detail = _title_detail(chapters=[chapter]) + chapter_data = {102305: ChapterMetadata(thumbnail_url="t5", chapter_id=102305, sub_title="Sub")} + + result = DownloadPlanner.filter_chapters_to_download( + chapter_data, + title_detail, + existing_files=[], + requested_chapter_ids={102305}, + ) + + assert result == [102305] + + +def test_find_chapter_by_id_returns_match_and_none() -> None: + """Verify chapter lookup returns chapter object when found, else None.""" + chapter = _chapter(1024959, "#1024959") + title_detail = _title_detail(chapters=[chapter]) + + assert ChapterPlanner.find_chapter_by_id(title_detail, 1024959) is chapter + assert ChapterPlanner.find_chapter_by_id(title_detail, 102278) is None + + +def test_title_detail_with_selected_chapters_adds_direct_fallback_chapter() -> None: + """Verify direct-ID fallback chapters are available to downstream filtering.""" + existing = _chapter(1, "#1") + fallback = _chapter(2, "#2") + title_detail = _title_detail(chapters=[existing]) + + augmented = title_detail_with_selected_chapters(title_detail, [existing, fallback]) + + assert augmented is not title_detail + assert augmented.find_chapter(1) is existing + assert augmented.find_chapter(2) is fallback + + +def test_prepare_filename_keeps_text_when_mojibake_fix_fails() -> None: + """Verify filename sanitizer still returns safe text on decode failures.""" + assert FilenamePolicy.prepare_filename("A\u20ac!") == "A" diff --git a/tests/test_runtime_page_export.py b/tests/test_runtime_page_export.py new file mode 100644 index 0000000..47a3239 --- /dev/null +++ b/tests/test_runtime_page_export.py @@ -0,0 +1,175 @@ +"""Tests for page download, decryption, and export behavior.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Iterator + +import click +import pytest + +from mloader.constants import PageType +from mloader.manga_loader.page_export import PageExportService, PageImageService +from mloader.types import ExporterLike, PageIndex +from tests.downloader_helpers import ( + DummyResponse, + DummySession, + manga_page as _manga_page, +) + + +def test_process_chapter_pages_handles_double_pages(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify DOUBLE page types are converted into ranged page indexes.""" + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + calls: list[tuple[bytes, Any]] = [] + + class FakeExporter(ExporterLike): + """Exporter test double recording add_image calls.""" + + def skip_image(self, index: PageIndex) -> bool: + """Never skip images in this test exporter.""" + del index + return False + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Record blob and page index for assertions.""" + calls.append((image_data, index)) + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + pages = [ + _manga_page("u1", page_type=PageType.DOUBLE), + _manga_page("u2", page_type=PageType.SINGLE), + ] + + PageExportService.export_pages( + pages, + chapter_name="#1", + exporter=FakeExporter(), + fetch_page_image=lambda page: f"img:{page.image_url}".encode("utf-8"), + ) + + assert calls[0][0] == b"img:u1" + assert calls[0][1] == range(0, 1) + assert calls[1][0] == b"img:u2" + assert calls[1][1] == 2 + + +def test_download_image_calls_raise_for_status() -> None: + """Verify image downloads call response.raise_for_status before returning bytes.""" + response = DummyResponse(content=b"img") + session = DummySession(response) + + result = PageImageService.download_image(session, (0.1, 0.1), "http://img") + + assert session.calls == ["http://img"] + assert response.status_checked is True + assert result == b"img" + + +def test_process_chapter_pages_skips_when_exporter_requests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify page export skips add_image when exporter says to skip.""" + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + class SkipExporter(ExporterLike): + """Exporter test double that always skips images.""" + + def skip_image(self, index: PageIndex) -> bool: + """Return True for every page index.""" + del index + return True + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Fail test if add_image is called while skip_image is True.""" + del image_data, index + raise AssertionError("add_image should not be called when skip_image is True") + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + pages = [_manga_page("u1", page_type=PageType.SINGLE)] + PageExportService.export_pages( + pages, + chapter_name="#1", + exporter=SkipExporter(), + fetch_page_image=lambda _page: b"unused", + ) + + +def test_process_chapter_pages_uses_decrypt_for_encrypted_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify encrypted pages route through decrypt path before export.""" + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + captured: list[bytes] = [] + + class CapturingExporter(ExporterLike): + """Exporter test double collecting written image bytes.""" + + def skip_image(self, index: PageIndex) -> bool: + """Never skip pages in this test.""" + del index + return False + + def add_image(self, image_data: bytes, index: PageIndex) -> None: + """Store output image blobs for assertion.""" + del index + captured.append(image_data) + + def close(self) -> None: + """Accept exporter finalization without side effects.""" + + pages = [ + _manga_page("u1", page_type=PageType.SINGLE, encryption_key="abcd"), + _manga_page("u2", page_type=PageType.SINGLE, encryption_key=""), + ] + PageExportService.export_pages( + pages, + chapter_name="#1", + exporter=CapturingExporter(), + fetch_page_image=lambda page: PageImageService.fetch_page_image( + page, + download_image=lambda url: f"img:{url}".encode("utf-8"), + decrypt_image=lambda url, key: bytearray(f"dec:{url}:{key}".encode("utf-8")), + ), + ) + + assert captured == [b"dec:u1:abcd", b"img:u2"] + + +def test_page_image_service_decrypts_encrypted_payload() -> None: + """Verify page-image service owns encrypted-image download and decryption.""" + response = DummyResponse(content=bytes([0x40])) + session = DummySession(response) + + assert PageImageService.decrypt_image(session, (0.1, 0.1), "http://img", "01") == bytearray( + [0x41] + ) + assert session.calls == ["http://img"] + assert response.status_checked is True diff --git a/tests/test_runtime_title_download.py b/tests/test_runtime_title_download.py new file mode 100644 index 0000000..d535440 --- /dev/null +++ b/tests/test_runtime_title_download.py @@ -0,0 +1,641 @@ +"""Tests for title-level download orchestration.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest + +from mloader.manga_loader.chapter_planning import ChapterMetadata +from mloader.manga_loader.filename_policy import FilenamePolicy +from mloader.manga_loader import title_download as title_download_module +from mloader.manga_loader.manifest_tracking import ManifestTracker +from tests.downloader_helpers import ( + dummy_downloader, + full_downloader, + chapter as _chapter, + run_report as _run_report, + title_detail as _title_detail, + title_plan as _title_plan, +) + + +class FakeManifestBase: + """Manifest double implementing the title-download manifest protocol.""" + + def __init__(self, _export_path: Path | None = None, *, autosave: bool = False) -> None: + """Accept manifest constructor arguments used by the runtime factory.""" + del _export_path, autosave + + def reset(self) -> None: + """No-op reset.""" + + def flush(self) -> None: + """No-op flush.""" + + def is_completed(self, chapter_id: int) -> bool: + """Treat every chapter as incomplete by default.""" + del chapter_id + return False + + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + """No-op start marker.""" + del chapter_id, chapter_name, sub_title, output_format + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + """No-op completion marker.""" + del chapter_id, output_path + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + """No-op failure marker.""" + del chapter_id, error + + +def test_manifest_tracker_mark_failed_noops_without_active_manifest() -> None: + """Verify manifest failure tracking is disabled unless resumable state is active.""" + manifest = FakeManifestBase() + + ManifestTracker.mark_failed(manifest, resume=False, chapter_id=1, error="boom") + ManifestTracker.mark_failed(None, resume=True, chapter_id=1, error="boom") + + +def test_process_title_with_no_chapters_to_download( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_title returns early when no chapters remain.""" + downloader = full_downloader() + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=_run_report()) + + +def test_process_title_clears_title_cache_after_processing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title-level cache clear hook runs after title processing.""" + downloader = full_downloader() + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + clear_calls: list[tuple[int, set[int]]] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + monkeypatch.setattr( + downloader, + "_clear_api_caches_for_title", + lambda title_id, chapter_ids: clear_calls.append((title_id, set(chapter_ids))), + ) + + downloader._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2}), + report=_run_report(), + ) + + assert clear_calls == [(10, {1, 2})] + + +def test_process_title_downloads_sorted_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_title processes candidate chapters in sorted order.""" + downloader = full_downloader() + title_dump = _title_detail(chapters=[_chapter(3, "#3", "sub")]) + processed: list[tuple[int, int, int]] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr( + downloader, + "_extract_chapter_data", + lambda _dump: {3: ChapterMetadata(thumbnail_url="", chapter_id=3, sub_title="sub")}, + raising=False, + ) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [5, 2, 3] + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda title, index, total, chapter_id, **kwargs: processed.append( + (index, total, chapter_id) + ), + ) + + downloader._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={2, 3, 5}), + report=_run_report(), + ) + + assert processed == [(1, 3, 2), (2, 3, 3), (3, 3, 5)] + + +def test_process_title_dumps_metadata_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify metadata export is invoked when loader meta flag is enabled.""" + downloader = full_downloader(meta=True) + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + calls = {"metadata": 0} + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + monkeypatch.setattr( + downloader, + "_dump_title_metadata", + lambda *_args, **_kwargs: calls.__setitem__("metadata", calls["metadata"] + 1), + ) + + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=_run_report()) + + assert calls["metadata"] == 1 + + +def test_process_title_dumps_cover_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cover export is invoked when loader cover flag is enabled.""" + downloader = dummy_downloader(cover=True) + title_detail = _title_detail(name="Title", author="Author", chapters=[]) + calls = {"cover": 0} + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_detail, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr( + downloader, + "_dump_title_cover", + lambda *_args, **_kwargs: calls.__setitem__("cover", calls["cover"] + 1), + raising=False, + ) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=_run_report()) + + assert calls["cover"] == 1 + + +def test_process_title_cover_export_failure_logs_warning_and_continues( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify cover export failures do not abort title processing.""" + downloader = dummy_downloader(cover=True) + title_detail = _title_detail(name="Title", author="Author", chapters=[]) + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_detail, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr( + downloader, + "_dump_title_cover", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("cover failed")), + raising=False, + ) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=_run_report()) + + assert "Cover export failed" in caplog.text + + +def test_process_title_skips_manifest_completed_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title processing excludes chapter IDs marked completed in manifest.""" + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + processed: list[int] = [] + + class FakeManifest(FakeManifestBase): + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + """Store nothing; behavior is fully deterministic for the test.""" + del autosave + + def is_completed(self, chapter_id: int) -> bool: + """Mark chapter 2 as already completed.""" + return chapter_id == 2 + + def flush(self) -> None: + """No-op flush for downloader finalize hooks.""" + + downloader = full_downloader(manifest_factory=FakeManifest) + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2, 3] + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: processed.append(chapter_id), + ) + + report = _run_report() + downloader._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2, 3}), + report=report, + ) + + assert processed == [1, 3] + assert report.skipped_manifest == 1 + + +def test_process_title_records_failed_chapter_report( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify per-chapter failures are collected and run continues.""" + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + processed: list[int] = [] + marked_failed: list[int] = [] + flush_calls = 0 + + class FakeManifest(FakeManifestBase): + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + del error + marked_failed.append(chapter_id) + + def flush(self) -> None: + nonlocal flush_calls + flush_calls += 1 + + def _process_chapter( + _title: Any, + _index: int, + _total: int, + chapter_id: int, + **kwargs: Any, + ) -> None: + del kwargs + if chapter_id == 2: + raise RuntimeError("boom") + processed.append(chapter_id) + + downloader = full_downloader(manifest_factory=FakeManifest) + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2, 3] + ) + monkeypatch.setattr(downloader, "_process_chapter", _process_chapter) + + report = _run_report() + downloader._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2, 3}), + report=report, + ) + + assert processed == [1, 3] + assert report.downloaded == 2 + assert report.failed == 1 + assert report.failed_chapter_ids == [2] + assert marked_failed == [2] + assert flush_calls >= 2 + + +def test_process_title_renames_legacy_files_when_requested( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify existing legacy chapter files are renamed before filtering.""" + title_id = 10 + chapter_id = 1 + chapter = _chapter(chapter_id, "#1", "Sub") + title_dump = _title_detail( + title_id=title_id, + name="My Manga", + chapters=[chapter], + ) + title_dump = replace(title_dump, title=replace(title_dump.title, language=8)) + title_name = FilenamePolicy.title_directory_name("My Manga") + legacy_file = FilenamePolicy.build_expected_filename( + title_name, + chapter, + "Sub", + 8, + filename_style="legacy", + ) + expected_file = FilenamePolicy.build_expected_filename( + title_name, + chapter, + "Sub", + 8, + filename_style="new", + ) + export_path = tmp_path / title_name + export_path.mkdir(parents=True) + (export_path / f"{legacy_file}.pdf").write_text("already-downloaded") + + downloader = full_downloader(destination=str(tmp_path)) + downloader.context = replace( + downloader.context, + filename_style="new", + rename_existing_filenames=True, + ) + processed: list[int] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr( + downloader, + "_extract_chapter_data", + lambda _dump: {chapter_id: ChapterMetadata("", chapter_id, "Sub")}, + raising=False, + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda *args, **kwargs: processed.append(chapter_id), + ) + + title_plan = _title_plan(title_id=title_id, chapter_ids={chapter_id}) + title_plan = replace(title_plan, title_detail=title_dump) + + downloader._process_title( + 1, + 1, + title_plan, + report=_run_report(), + ) + + assert processed == [] + assert not (export_path / f"{legacy_file}.pdf").exists() + assert (export_path / f"{expected_file}.pdf").exists() + + +def test_rename_existing_filenames_skips_unsupported_output_format() -> None: + """Verify filename migration is skipped for image output formats.""" + title_detail = _title_detail(name="My Manga", chapters=[_chapter(1, "#1")]) + title_name = FilenamePolicy.title_directory_name("My Manga") + expected_file = FilenamePolicy.build_expected_filename( + title_name, + _chapter(1, "#1"), + "Sub", + 0, + filename_style="new", + ) + + export_path = Path("/tmp") / title_name + export_path.mkdir(exist_ok=True) + title_download_module._rename_existing_filenames_to_style( + output_format="raw", + export_path=export_path, + title_detail=title_detail, + chapter_data={1: ChapterMetadata("", 1, "Sub")}, + filename_style="new", + ) + + assert not (export_path / f"{expected_file}.raw").exists() + + +def test_rename_existing_filenames_handles_missing_chapter_data() -> None: + """Verify migration ignores stale metadata entries with missing chapter IDs.""" + title_detail = _title_detail(name="My Manga", chapters=[]) + export_path = Path("/tmp") / "My Manga" + export_path.mkdir(exist_ok=True) + original_files = set(export_path.glob("*")) + + title_download_module._rename_existing_filenames_to_style( + output_format="pdf", + export_path=export_path, + title_detail=title_detail, + chapter_data={1: ChapterMetadata("", 1, "Sub")}, + filename_style="new", + ) + + assert set(export_path.glob("*")) == original_files + + +def test_rename_existing_filenames_skips_when_style_is_unchanged() -> None: + """Verify migration skips entries when the legacy and target filename styles are equal.""" + title = _title_detail(name="My Manga", chapters=[_chapter(1, "#1", "")]) + title = replace(title, title=replace(title.title, language=8)) + title_name = FilenamePolicy.title_directory_name("My Manga") + legacy_file = FilenamePolicy.build_expected_filename( + title_name, + _chapter(1, "#1", ""), + "", + 8, + filename_style="legacy", + ) + + export_path = Path("/tmp") / title_name + export_path.mkdir(exist_ok=True) + expected_path = export_path / f"{legacy_file}.pdf" + expected_path.write_text("already-downloaded") + + title_download_module._rename_existing_filenames_to_style( + output_format="pdf", + export_path=export_path, + title_detail=title, + chapter_data={1: ChapterMetadata("", 1, "")}, + filename_style="legacy", + ) + + assert expected_path.exists() + + +def test_process_title_on_keyboard_interrupt_marks_manifest_and_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupt during chapter processing marks failure, flushes, and re-raises.""" + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + marked_failed: list[int] = [] + flush_calls = 0 + + class FakeManifest(FakeManifestBase): + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + del error + marked_failed.append(chapter_id) + + def flush(self) -> None: + nonlocal flush_calls + flush_calls += 1 + + downloader = full_downloader(manifest_factory=FakeManifest) + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1]) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + report = _run_report() + with pytest.raises(KeyboardInterrupt): + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=report) + + assert report.failed == 1 + assert report.failed_chapter_ids == [1] + assert marked_failed == [1] + assert flush_calls >= 2 + + +def test_process_title_resume_skips_completed_and_retries_failed_after_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify manifest resume across two runs skips completed and retries failed chapters.""" + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + + first_run = full_downloader(destination=str(tmp_path)) + first_attempts: list[int] = [] + + def _first_process_chapter( + _title: Any, + _index: int, + _total: int, + chapter_id: int, + **kwargs: Any, + ) -> None: + manifest = kwargs.get("manifest") + first_attempts.append(chapter_id) + if chapter_id == 2: + raise RuntimeError("boom") + if manifest is not None: + manifest.mark_completed(chapter_id) + + monkeypatch.setattr(first_run, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(first_run, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(first_run, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(first_run, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr(first_run, "_process_chapter", _first_process_chapter) + + first_report = _run_report() + first_run._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2}), + report=first_report, + ) + + assert first_attempts == [1, 2] + assert first_report.downloaded == 1 + assert first_report.failed == 1 + assert first_report.failed_chapter_ids == [2] + + second_run = full_downloader(destination=str(tmp_path)) + second_attempts: list[int] = [] + + monkeypatch.setattr(second_run, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(second_run, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(second_run, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(second_run, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr( + second_run, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: second_attempts.append(chapter_id), + ) + + second_report = _run_report() + second_run._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2}), + report=second_report, + ) + + assert second_attempts == [2] + assert second_report.downloaded == 1 + assert second_report.skipped_manifest == 1 + assert second_report.failed == 0 + + +def test_process_title_disables_manifest_when_resume_is_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --no-resume mode skips manifest-based chapter filtering.""" + downloader = full_downloader(resume=False) + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + processed: list[int] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: processed.append(chapter_id), + ) + + report = _run_report() + downloader._process_title( + 1, + 1, + _title_plan(title_id=10, chapter_ids={1, 2}), + report=report, + ) + + assert processed == [1, 2] + assert report.skipped_manifest == 0 + + +def test_process_title_resets_manifest_when_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --manifest-reset clears existing per-title manifest state.""" + title_dump = _title_detail(name="My Manga", author="A", chapters=[]) + reset_calls = 0 + + class FakeManifest(FakeManifestBase): + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def reset(self) -> None: + nonlocal reset_calls + reset_calls += 1 + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def flush(self) -> None: + """No-op flush for downloader finalize hooks.""" + + downloader = full_downloader(manifest_reset=True, manifest_factory=FakeManifest) + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + downloader._process_title(1, 1, _title_plan(title_id=10, chapter_ids={1}), report=_run_report()) + + assert reset_calls == 1 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..8982611 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +"""Tests for generic utility helper functions.""" + +from __future__ import annotations + +import pytest + +from mloader import utils + + +def test_contains_keywords_is_case_insensitive() -> None: + """Verify keyword matching is case-insensitive and requires all keywords.""" + assert utils._contains_keywords("One Shot story", ["one", "SHOT"]) is True + assert utils._contains_keywords("only one", ["one", "shot"]) is False + + +def test_is_oneshot_detects_keywords_and_numbered_chapters() -> None: + """Verify one-shot detection for keyword and numeric chapter naming.""" + assert utils.is_oneshot("#12", "one shot") is False + assert utils.is_oneshot("One shot special", "") is True + assert utils.is_oneshot("Special", "A one shot finale") is True + assert utils.is_oneshot("Special", "Finale") is False + + +def test_chapter_name_to_int_handles_invalid_names() -> None: + """Verify chapter number extraction for valid and invalid names.""" + assert utils.chapter_name_to_int("#42") == 42 + assert utils.chapter_name_to_int("abc") is None + + +def test_escape_path_normalizes_special_characters() -> None: + """Verify unsafe filesystem characters are normalized.""" + assert utils.escape_path(" hello:/world!? ") == "hello world" + + +def test_is_windows_checks_platform(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify platform detection maps correctly for Windows and non-Windows.""" + monkeypatch.setattr(utils.sys, "platform", "win32") + assert utils.is_windows() is True + + monkeypatch.setattr(utils.sys, "platform", "linux") + assert utils.is_windows() is False diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..d3115e9 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,93 @@ +"""Tests for CLI callback validators.""" + +from __future__ import annotations + +import click +import pytest + +from mloader.cli.validators import validate_ids, validate_urls + + +def _option(name: str) -> click.Option: + """Return a Click option with the production callback parameter name.""" + return click.Option([f"--{name.replace('_', '-')}"]) + + +def test_validate_urls_collects_chapter_ids_and_titles() -> None: + """Verify URL callback extracts viewer chapter IDs and title IDs into context sets.""" + ctx = click.Context(click.Command("mloader")) + + value = ( + "https://mangaplus.shueisha.co.jp/viewer/1024959", + "https://mangaplus.shueisha.co.jp/titles/100312", + "viewer/102278", + ) + + returned = validate_urls(ctx, None, value) + + assert returned == value + assert ctx.params["chapter_ids"] == {1024959, 102278} + assert ctx.params["titles"] == {100312} + + +def test_validate_urls_rejects_invalid_url() -> None: + """Verify malformed URLs raise a click validation error.""" + ctx = click.Context(click.Command("mloader")) + + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("not-a-url",)) + + +def test_validate_urls_rejects_invalid_host() -> None: + """Verify URLs outside allowed MangaPlus hosts are rejected.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("https://example.com/viewer/1024959",)) + + +def test_validate_urls_rejects_unsupported_segment() -> None: + """Verify unknown URL path keys are rejected by validator callback.""" + ctx = click.Context(click.Command("mloader")) + + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("https://mangaplus.shueisha.co.jp/chapter/1024959",)) + + +def test_validate_urls_accepts_empty_input() -> None: + """Verify empty URL argument lists are passed through unchanged.""" + ctx = click.Context(click.Command("mloader")) + + assert validate_urls(ctx, None, ()) == () + + +def test_validate_ids_updates_context_for_all_supported_target_types() -> None: + """Verify ID callback updates chapter-number, chapter-ID, and title target sets.""" + ctx = click.Context(click.Command("mloader")) + + validate_ids(ctx, _option("chapter"), (12, 13, 13)) + validate_ids(ctx, _option("chapter_id"), (1024959, 102278, 102278)) + validate_ids(ctx, _option("title"), (100312,)) + + assert ctx.params["chapters"] == {12, 13} + assert ctx.params["chapter_ids"] == {1024959, 102278} + assert ctx.params["titles"] == {100312} + + +def test_validate_ids_accepts_empty_input() -> None: + """Verify empty numeric ID lists are passed through unchanged.""" + ctx = click.Context(click.Command("mloader")) + assert validate_ids(ctx, _option("chapter"), ()) == () + + +def test_validate_ids_rejects_missing_param_metadata() -> None: + """Verify validator raises click.BadParameter when param metadata is absent.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter): + validate_ids(ctx, None, (1024959,)) + + +def test_validate_ids_rejects_unexpected_param_name() -> None: + """Verify validator raises click.BadParameter for unsupported parameter names.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter, match="Unexpected parameter"): + validate_ids(ctx, _option("unknown"), (1,)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9d3f134 --- /dev/null +++ b/uv.lock @@ -0,0 +1,558 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "img2pdf" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pikepdf" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/97/ca44c467131b93fda82d2a2f21b738c8bcf63b5259e3b8250e928b8dd52a/img2pdf-0.6.3.tar.gz", hash = "sha256:219518020f5bd242bdc46493941ea3f756f664c2e86f2454721e74353f58cd95", size = 120350, upload-time = "2025-11-05T20:51:57.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/dc/91e3a4a11c25ae183bd5a71b84ecb298db76405ff70013f76b10877bdfe3/img2pdf-0.6.3-py3-none-any.whl", hash = "sha256:44d12d235752edd17c43c04ff39952cdc5dd4c6aba90569c4902bd445085266b", size = 49701, upload-time = "2025-11-05T20:51:55.469Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780, upload-time = "2026-04-12T16:28:24.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045, upload-time = "2026-04-12T16:25:33.57Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963, upload-time = "2026-04-12T16:25:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832, upload-time = "2026-04-12T16:25:39.104Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865, upload-time = "2026-04-12T16:25:41.226Z" }, + { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001, upload-time = "2026-04-12T16:25:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303, upload-time = "2026-04-12T16:25:46.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940, upload-time = "2026-04-12T16:25:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050, upload-time = "2026-04-12T16:25:52.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409, upload-time = "2026-04-12T16:25:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072, upload-time = "2026-04-12T16:25:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779, upload-time = "2026-04-12T16:26:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953, upload-time = "2026-04-12T16:26:02.289Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605, upload-time = "2026-04-12T16:26:04.694Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886, upload-time = "2026-04-12T16:26:07.659Z" }, + { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382, upload-time = "2026-04-12T16:26:10.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164, upload-time = "2026-04-12T16:26:59.568Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512, upload-time = "2026-04-12T16:27:02.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745, upload-time = "2026-04-12T16:27:04.444Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437, upload-time = "2026-04-12T16:26:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892, upload-time = "2026-04-12T16:26:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173, upload-time = "2026-04-12T16:26:18.335Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134, upload-time = "2026-04-12T16:26:20.612Z" }, + { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205, upload-time = "2026-04-12T16:26:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461, upload-time = "2026-04-12T16:26:25.316Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378, upload-time = "2026-04-12T16:26:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415, upload-time = "2026-04-12T16:26:31.513Z" }, + { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953, upload-time = "2026-04-12T16:26:33.806Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532, upload-time = "2026-04-12T16:26:36.518Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767, upload-time = "2026-04-12T16:26:39.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079, upload-time = "2026-04-12T16:26:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686, upload-time = "2026-04-12T16:26:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259, upload-time = "2026-04-12T16:26:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190, upload-time = "2026-04-12T16:26:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005, upload-time = "2026-04-12T16:26:52.163Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037, upload-time = "2026-04-12T16:26:54.398Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184, upload-time = "2026-04-12T16:26:57.011Z" }, +] + +[[package]] +name = "mloader-ng" +version = "2.1.2" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "img2pdf" }, + { name = "pillow" }, + { name = "playwright" }, + { name = "protobuf" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.3.1" }, + { name = "filelock", specifier = ">=3.20.0" }, + { name = "img2pdf", specifier = ">=0.6.3" }, + { name = "pillow", specifier = ">=12.0.0" }, + { name = "playwright", specifier = ">=1.55.0" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "urllib3", specifier = ">=2.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-cov", specifier = ">=6.2.0" }, + { name = "ruff", specifier = ">=0.13.0" }, + { name = "ty", specifier = ">=0.0.24" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pikepdf" +version = "10.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "lxml" }, + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/66/32a45480d84cb239c7ad31209c956496fe5b20f6fb163d794db4c79f840c/pikepdf-10.5.1.tar.gz", hash = "sha256:ffa6c7d0b77deb3af9735e0b0cae177c897431e10d342bb171b62e5527a622b7", size = 4582470, upload-time = "2026-03-18T07:56:00.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/48/b513468b4a5c7d4d9007c2c9b59686d15cca88c339225e4f2069c15799f9/pikepdf-10.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:ac7a96d6e4a23cd2dfc07f2cec9f55b000fcd4be4be888dbbe5a767dcd4c409e", size = 4770764, upload-time = "2026-03-18T07:55:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/2d/42/da2abc72d0d04b48158b6bccad6e31dfd2cef63f501cdb6192af100d7545/pikepdf-10.5.1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:1b81fd3f3de40ec4ad87fe9337d91e5f1301d4c4450ff02f4aa581c76609a3b7", size = 5089799, upload-time = "2026-03-18T07:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/1d/47/19731ef57be6007fb5007438618d6803d7abb4adaa095e55a8e7bd5cfa25/pikepdf-10.5.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:781ee394f38ebdf412dc674a1e3333a844dbab673d4d8db04050055062b8fa8d", size = 2493677, upload-time = "2026-03-18T07:55:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/25/87/3e115b7a47c3a5bb3a58a7ba51de20d4964393735fab0085fc94a979113b/pikepdf-10.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17e6a136b870424e66167035406662842783049ee14262f49ed2572caa584475", size = 2736452, upload-time = "2026-03-18T07:55:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2b/f30006c28b58a12116561fe4fe62c9cd630e97a88de799c27a3073c6bb55/pikepdf-10.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c9ae38d8dd7acf1b4c9bcd9a38fb75735bc62049c34634b1899e47068b314c5f", size = 3702958, upload-time = "2026-03-18T07:55:54.294Z" }, + { url = "https://files.pythonhosted.org/packages/83/47/d467f13394c3be1c57b0fbc4264a8f0d1f6ae42ae61299c4695a89b2d983/pikepdf-10.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:013411404eb129b8d0cd565ce09c4f99254b9837a8e66f90d150b7c1f23a7124", size = 3910716, upload-time = "2026-03-18T07:55:56.233Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b5/7753d726905f217b78e677fee82e877a6880bc326db3a13c1934884ed54b/pikepdf-10.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:70d1b5ae2e61ab5a08e2c978d18208766d5aa0747a2181c95d6e3acaa114f278", size = 3923717, upload-time = "2026-03-18T07:55:58.16Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +]