Files
shelfmark/tests
Lance Marks f441b85da2 feat(packs): inspect multi-book releases and file each book separately (#1270)
## Multi-book packs: inspect a release before download and file each
book separately

Closes #576

### Problem

One queued release is always treated as one book. When a torrent is
actually a whole series
(`Series/Book 1 - Title/…`, or a flat folder of `Series 1.0 - Title.m4b`
files), post-processing
walks the whole tree, flattens every file into one list and renames them
`Title - 01…10` under the
searched book's `{Author}/{Title}`. Audiobookshelf then sees a single
10-file "book" and the user
has to re-file everything by hand.

### What this does

Most releases expose their file list *before* anything is downloaded, so
the split is decided up
front and approved by the user, then the download is fire-and-forget:

1. **Inspect** – clicking a release's download button now calls `POST
/api/releases/inspect`
first. A new optional `DownloadHandler.list_files(release_data)` hook
returns the release's
   files without downloading:
- **AudiobookBay** reads the torrent file table off the detail page it
already fetches (the
page is now cached for 120 s, so inspect + download cost ABB one
request).
- **Prowlarr** parses `info.files` from the `.torrent` it already
fetches (the existing 120 s
torrent-fetch cache is reused). Magnet-only and usenet releases report
"can't inspect".
   - Other sources default to `None`.
2. **Review** – if the plan contains more than one book, the Find
Releases modal swaps the list
for a review panel: one row per book with editable title / series
position / year, expandable
file lists, non-book sidecars (`.txt`, covers) shown as ignored, a
"Treat as a single book"
switch, and **Download N books**. Single-book releases queue
immediately, exactly as before.
3. **File** – the approved plan travels with the task
(`DownloadTask.book_plan`, retry-safe) and
post-processing files each book through the existing transfer code, one
book at a time
(`dataclasses.replace(task, title=…, series_position=…, year=…)`), so
organize/rename
templates, part numbering (now scoped per book), hardlinks, torrent
copy-preserve and usenet
   handling are unchanged. Status reads `Complete (N books, M files)`.
4. **Fallback** – when a release can't be inspected the user gets a
toast, and a small
"Multi-book pack" toggle in the modal header forces a heuristic split
(subfolder = book, or
   one book per file when the file names carry series positions).

Planning lives in `shelfmark/download/postprocess/packs.py` and is
shared by the inspect endpoint
and post-processing, so what the user approved is what gets filed. The
name parser strips
`Book 3 -`, `03 -`, `1.0 -`, `3.`, `[03]`, `#3`, a leading series name,
labels like
"An Expanse Novella -", repeated titles (`Gods of Risk 2.5 - Gods of
Risk`) and a trailing
`(Year)`; author and series name come from the book that was searched,
and the searched book's
own series position is never applied to its siblings.

### Files

- `shelfmark/download/postprocess/packs.py` (new) –
`PackFile/PackBook/PackPlan`, `plan_pack`,
`parse_pack_book_name`, `group_files_into_books`, `match_plan_to_files`
- `shelfmark/core/release_inspect_routes.py` (new) – `POST
/api/releases/inspect`
- `shelfmark/release_sources/__init__.py` – `DownloadHandler.list_files`
hook
- `shelfmark/release_sources/audiobookbay/{scraper,handler}.py` –
detail-page cache,
  `extract_file_list`, `list_files`
- `shelfmark/release_sources/prowlarr/handler.py`,
`download/clients/torrent_utils.py` –
  `extract_file_list_from_torrent`, `list_files`
- `shelfmark/core/models.py`, `download/orchestrator.py` – `multi_book`
/ `book_plan` fields,
  queue + retry serialization
- `shelfmark/download/postprocess/transfer.py`, `pipeline.py`,
`outputs/folder.py` – per-book
  transfer branch and status message
- `src/frontend`: `components/PackReviewPanel.tsx` (new),
`ReleaseModal.tsx`, `App.tsx`,
`services/api.ts`, `types/index.ts`, `utils/releasePayload.ts` (payload
builder moved out of
  `App.tsx`), `utils/packReview.ts`
- `docs/dev/release-sources-plugin-guide.md` – documents the
`list_files` hook

### Out of scope (follow-ups)

- Listing files from an NZB (Shelfmark already fetches the bytes; `<file
subject>` names are noisy)
- Inspecting magnet links via qBittorrent's files API after a paused add
- BookLore / email outputs (they ignore `book_plan`; noted in code)
- The combined ebook + audiobook flow

### Testing

**Automated** (`make checks`, `make python-test`, `make frontend-test`
all green; the only
failures on my machine are the pre-existing
`tests/config/test_entrypoint_permissions.py` cases,
which need bash ≥ 4 and fail identically on `main` under macOS bash
3.2):

- `tests/download/test_packs.py` – name parsing (markers, series name,
novella labels, repeated
titles, bare numeric titles like `1984`), nested / flat / mixed /
deeper-nested packs, single
wrapping folder not treated as a pack, plan-to-disk matching with
basename fallback
- `tests/core/test_processing_packs.py` – full `post_process_download`
runs on a real temp
filesystem: approved plan files each book under its own
`{Author}/{Title}`, heuristic split
of a nested pack, searched book's series position does not leak,
multi-file book inside a pack
keeps `- 01/- 02` per book, hardlinked torrent pack leaves the seeding
tree intact, no pack
fields ⇒ behaviour unchanged, single group degrades to the searched
title, status message
- `tests/core/test_release_inspect_routes.py` – plan response,
not-inspectable, handler errors
  never 500, unknown source / missing `source_id` ⇒ 400, login required
- `tests/audiobookbay/test_file_list.py` – file-table scraping from real
ABB markup (multi-file
and single-file pages), handler host validation, one page fetch shared
by magnet + file list
- `tests/prowlarr/test_torrent_file_list.py` – multi-file / single-file
`.torrent` parsing,
  handler behaviour for torrent URL vs magnet vs usenet vs cache miss
- `tests/download/test_orchestrator_pack_fields.py` – queue-time parsing
and retry round-trip
- Frontend: `releasePayload.test.ts`, `packReview.test.ts` (vitest)

**Manual, on a real deployment** (arm64 image built from this branch,
run as a side container
next to production with the same qBittorrent / Audiobookshelf setup,
`FILE_ORGANIZATION_AUDIOBOOK=organize`,
hardlinks on):

- AudiobookBay "The Expanse Complete 2.0" (7.87 GB, 36 files): clicking
download opened the review
panel in ~1 s showing **18 books · 18 files · 18 files ignored** (the
`.txt` sidecars), with
series positions 0.1–9.5 and years parsed from the file names; novella
labels stripped
("The Churn", "The Butcher of Anderson Station"). Editing a title in the
panel works.
Confirming queued one task; the magnet resolved from the cached page in
~30 ms; after the
download the task reported `Complete (18 books, 18 files)`, 18 hardlinks
landed as
`audiobooks/James S. A. Corey/<Title>/<Title>.m4b`, the torrent kept
seeding, and
Audiobookshelf scanned each folder as its own book (title, author,
embedded chapters).
- A second pack ("Expanse [01 - 9.5]", `Title N - Title` naming) was
inspected to verify the
  repeated-title rule and the Back button, without downloading.
- Single-book releases still queue immediately with no extra UI.
2026-08-27 00:40:08 -04:00
..

Test Suite

This directory contains the test suite for Shelfmark. Tests are organized by scope and component.

Quick Start

# Sync the local Python environment once
make install-python-dev

# Run all unit tests locally (fast, no external dependencies)
uv run pytest tests/ -v -m "not integration and not e2e"

# Run all Python static analysis (lint, format, typecheck, dead code)
make python-checks

# Run E2E API tests against a running app stack
uv run pytest tests/e2e/ -v -m e2e

# Run everything except integration tests locally
uv run pytest tests/ -v -m "not integration"

Test Structure

tests/
├── config/                 # Settings & configuration tests
│   ├── test_docker_volumes.py          # Docker volume mapping
│   ├── test_environment.py             # Environment variable handling
│   ├── test_mirror_settings_live_apply.py  # Mirror settings live reload
│   ├── test_mirror_settings_options.py # Mirror settings options
│   ├── test_security.py               # Security settings
│   └── test_oidc_settings.py          # OIDC settings fields & show_when conditions
│
├── core/                   # Core application logic tests
│   ├── test_admin_users_api.py         # Admin user CRUD API endpoints
│   ├── test_booklore_multiuser.py      # BookLore per-user override merging
│   ├── test_builtin_multiuser.py       # Builtin auth multi-user migration
│   ├── test_download_processing.py     # Download file processing
│   ├── test_hardlink.py               # Hardlink/copy operations
│   ├── test_library_processing.py      # Library file processing
│   ├── test_manual_query.py           # Manual search query handling
│   ├── test_mirrors_config.py         # Mirror configuration
│   ├── test_naming.py                 # File naming templates
│   ├── test_oidc_auth.py             # OIDC auth helpers (group claims, user provisioning)
│   ├── test_oidc_integration.py       # OIDC integration into auth system (logic mirror)
│   ├── test_oidc_routes.py           # OIDC Flask route handlers
│   ├── test_part_number_extraction.py  # Part number extraction
│   ├── test_per_user_downloads.py     # Per-user download queue filtering
│   ├── test_permission_handling.py    # File permission handling
│   ├── test_processing_integration.py  # Processing integration
│   ├── test_search_plan.py           # Search plan logic
│   ├── test_user_db.py               # UserDB CRUD operations
│   └── test_user_template_variable.py  # {User} template variable in naming
│
├── e2e/                    # End-to-end API tests
│   ├── conftest.py         # Fixtures (APIClient, DownloadTracker)
│   ├── test_api.py         # Core API endpoint tests
│   ├── test_download_flow.py   # Full download journey tests
│   └── test_prowlarr_flow.py   # Prowlarr-specific tests
│
├── prowlarr/               # Prowlarr plugin tests
│   ├── conftest.py         # Shared fixtures
│   ├── test_clients.py     # DownloadClient base, registry, DownloadStatus
│   ├── test_qbittorrent_client.py  # qBittorrent client unit tests
│   ├── test_transmission_client.py # Transmission client unit tests
│   ├── test_nzbget_client.py       # NZBGet client unit tests
│   ├── test_sabnzbd_client.py      # SABnzbd client unit tests
│   ├── test_handler.py     # ProwlarrHandler unit tests
│   ├── test_torrent_utils.py   # Bencode, hash extraction, URL parsing
│   ├── test_bencode.py     # Bencode encoding/decoding
│   ├── test_source.py      # Release source (size parsing, format detection)
│   ├── test_cache.py       # Release cache
│   ├── test_integration_clients.py  # Integration tests (require Docker stack)
│   └── test_integration_handler.py  # Handler integration tests
│
└── README.md               # This file

Test Types

Unit Tests

Fast tests that mock external dependencies. Run these frequently during development.

uv run pytest tests/prowlarr/ -v -m "not integration"

What they test:

  • Download client logic (status mapping, URL handling, error cases)
  • Bencode encoding/decoding for torrent files
  • Hash extraction from magnet links and .torrent files
  • Protocol detection (torrent vs usenet)
  • Release cache operations
  • Handler download flow logic
  • User database (CRUD, settings, OIDC subject linking)
  • OIDC authentication (group claims, user provisioning, route handlers)
  • Admin user management API (create, update, delete, password, per-user settings)
  • Multi-user download queue filtering and per-user overrides
  • Settings configuration (OIDC fields, show_when conditions)

E2E Tests

Test the full application through its HTTP API. Require the app to be running.

uv run pytest tests/e2e/ -v -m e2e

What they test:

  • Health check endpoint
  • Configuration endpoint
  • Metadata provider search (Hardcover, etc.)
  • Release source listing
  • Download queue operations (add, cancel, reorder, clear)
  • Settings API
  • Prowlarr integration

Integration Tests

Test against real services (qBittorrent, Transmission, etc.). Require the full Docker test stack.

# Start the test stack first
docker compose -f docker-compose.test-clients.yml up -d

# Run integration tests
docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest tests/prowlarr/ -v -m integration

What they test:

  • Real connections to download clients
  • Adding/removing actual torrents
  • Status polling from real clients

Test Markers

Marker Description When to Skip
integration Requires running services (qBittorrent, etc.) Default skip with -m "not integration"
e2e End-to-end API tests When app isn't running
slow Tests that take longer (network calls, polling) Quick feedback with -m "not slow"

Common Commands

# Run specific test file
uv run pytest tests/prowlarr/test_clients.py -v

# Run specific test class
uv run pytest tests/e2e/test_api.py::TestHealthEndpoint -v

# Run specific test
uv run pytest tests/e2e/test_api.py::TestHealthEndpoint::test_health_returns_ok -v

# Run with short traceback (cleaner output)
uv run pytest tests/ -v --tb=short -m "not integration"

# Run and stop on first failure
uv run pytest tests/ -v -x -m "not integration"

# Run with coverage (if pytest-cov installed)
uv run pytest tests/ --cov=shelfmark -m "not integration"

Writing New Tests

Unit Test Example

from unittest.mock import MagicMock, patch


class TestMyFeature:
    def test_something(self, monkeypatch):
        # Mock config values
        monkeypatch.setattr(
            "shelfmark.module.config.get",
            lambda key, default="": {"KEY": "value"}.get(key, default),
        )

        # Test your code
        result = my_function()
        assert result == expected

E2E Test Example

import pytest
from .conftest import APIClient, DownloadTracker


@pytest.mark.e2e
class TestMyEndpoint:
    def test_endpoint_works(self, protected_api_client: APIClient):
        resp = protected_api_client.get("/api/my-endpoint")
        assert resp.status_code == 200

    def test_with_cleanup(
        self,
        protected_api_client: APIClient,
        download_tracker: DownloadTracker,
    ):
        # Track IDs for automatic cleanup after test
        download_tracker.track("some-id")
        # ... test code ...

Test Fixtures

E2E Fixtures (tests/e2e/conftest.py)

Fixture Scope Description
api_client function Fresh HTTP client for general E2E calls
protected_api_client function Authenticated client for protected-route E2Es
download_tracker function Tracks downloads for cleanup
server_config session Cached server configuration

Prowlarr Fixtures (tests/prowlarr/conftest.py)

Fixture Scope Description
transmission_client module Real Transmission client (integration)
qbittorrent_client module Real qBittorrent client (integration)
deluge_client module Real Deluge client (integration)
nzbget_client module Real NZBGet client (integration)
sabnzbd_client module Real SABnzbd client (integration)

Expected Skips

Some tests skip when external services aren't available. This is normal:

  • "No metadata providers available" - Metadata provider not responding
  • "Prowlarr not configured" - Prowlarr settings not set up
  • "No releases found" - No indexers configured in Prowlarr
  • "Legacy search source unavailable" - Direct download source offline
  • "Transmission/qBittorrent not available" - Docker test stack not running

Troubleshooting

Tests can't connect to app

# Check the app/container is running
docker ps

# Check app logs
docker logs <your-shelfmark-container>

Import errors

# Sync the local Python environment first
uv sync --locked --extra browser

# Then run tests from the repo root
uv run pytest ...

Integration tests failing

# Make sure test stack is running
docker compose -f docker-compose.test-clients.yml up -d

# Check client containers
docker ps | grep -E "qbittorrent|transmission|deluge|nzbget|sabnzbd"

Stale test data

Restart the container to reset the in-memory queue between test runs:

docker restart <your-shelfmark-container>