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.
This commit is contained in:
Lance Marks
2026-08-27 00:40:08 -04:00
committed by GitHub
parent 02b7e9d958
commit f441b85da2
31 changed files with 2370 additions and 72 deletions
+8 -39
View File
@@ -58,7 +58,6 @@ import {
isApiResponseError,
updateSelfUser,
setBookTargetState,
type DownloadReleasePayload,
} from './services/api';
import type {
Book,
@@ -93,6 +92,7 @@ import { getEffectiveMetadataSort } from './utils/metadataSort';
import { isRecord } from './utils/objectHelpers';
import { policyTrace } from './utils/policyTrace';
import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets';
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from './utils/releasePayload';
import { applyRequestNoteToPayload } from './utils/requestConfirmation';
import { bookFromRequestData } from './utils/requestFulfil';
import {
@@ -219,6 +219,7 @@ type PendingOnBehalfDownload =
release: Release;
releaseContentType: ContentType;
actingAsUser: ActingAsUserSelection;
options?: ReleaseDownloadOptions;
}
| {
type: 'combined';
@@ -1072,41 +1073,6 @@ function App() {
[],
);
const buildReleaseDownloadPayload = useCallback(
(book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => {
const isManual = book.provider === 'manual';
const releasePreview =
typeof release.extra?.preview === 'string' ? release.extra.preview : undefined;
const releaseAuthor =
typeof release.extra?.author === 'string' ? release.extra.author : undefined;
return {
source: release.source,
source_id: release.source_id,
title: isManual ? release.title : book.title,
author: isManual ? releaseAuthor || '' : book.author,
year: book.year,
format: release.format,
size: release.size,
size_bytes: release.size_bytes,
download_url: release.download_url,
protocol: release.protocol,
indexer: release.indexer,
seeders: release.seeders,
extra: release.extra,
preview: isManual ? releasePreview || undefined : book.preview,
content_type: releaseContentType,
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
// From the release, never the book: book.language is the provider's
// canonical edition, which would mislabel a translated release.
language: release.language ?? undefined,
};
},
[],
);
// When downloading a book while browsing a Hardcover list the user owns,
// automatically remove it from that list (fire-and-forget).
const searchFieldLabelsRef = useRef(searchFieldLabels);
@@ -1214,12 +1180,13 @@ function App() {
release: Release,
releaseContentType: ContentType,
onBehalfOfUserId?: number,
options?: ReleaseDownloadOptions,
): Promise<void> => {
const requestStartedAtSeconds = Date.now() / 1000;
try {
trackRelease(book.id, release.source_id);
await downloadRelease(
buildReleaseDownloadPayload(book, release, releaseContentType),
buildReleaseDownloadPayload(book, release, releaseContentType, options),
onBehalfOfUserId,
);
await fetchStatus();
@@ -1301,7 +1268,6 @@ function App() {
}
},
[
buildReleaseDownloadPayload,
fetchStatus,
openRequestConfirmation,
refreshRequestPolicy,
@@ -1416,6 +1382,7 @@ function App() {
effectivePendingOnBehalfDownload.release,
effectivePendingOnBehalfDownload.releaseContentType,
onBehalfOfUserId,
effectivePendingOnBehalfDownload.options,
);
}
setPendingOnBehalfDownload(null);
@@ -1640,6 +1607,7 @@ function App() {
book: Book,
release: Release,
releaseContentType: ContentType,
options?: ReleaseDownloadOptions,
) => {
policyTrace('release.action:start', {
bookId: book.id,
@@ -1655,11 +1623,12 @@ function App() {
release,
releaseContentType,
actingAsUser: effectiveActingAsUser,
options,
});
return;
}
await executeReleaseDownload(book, release, releaseContentType);
await executeReleaseDownload(book, release, releaseContentType, undefined, options);
};
const handleReleaseRequest = useCallback(
@@ -0,0 +1,193 @@
import { useState } from 'react';
import type { PackBook, PackPlan, Release } from '../types';
import {
describePackPlan,
parseSeriesPositionInput,
toBookPlanPayload,
updateReviewBook,
} from '../utils/packReview';
import { ToggleSwitch } from './shared/ToggleSwitch';
interface PackReviewPanelProps {
release: Release;
plan: PackPlan;
books: PackBook[];
onChange: (books: PackBook[]) => void;
onBack: () => void;
/** `null` means "treat the whole release as one book". */
onConfirm: (books: PackBook[] | null) => Promise<void>;
isSubmitting: boolean;
}
const inputClassName =
'w-full rounded-md border border-(--border-muted) bg-(--bg) px-2 py-1 text-sm text-(--text) focus:border-emerald-500 focus:outline-none';
export const PackReviewPanel = ({
release,
plan,
books,
onChange,
onBack,
onConfirm,
isSubmitting,
}: PackReviewPanelProps) => {
const [singleBook, setSingleBook] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<number | null>(null);
const [showIgnored, setShowIgnored] = useState(false);
const payloadBooks = toBookPlanPayload(books);
const canConfirm = !isSubmitting && (singleBook || payloadBooks.length > 0);
const confirmLabel = singleBook
? 'Download as one book'
: `Download ${payloadBooks.length} ${payloadBooks.length === 1 ? 'book' : 'books'}`;
return (
<div className="flex flex-col gap-4 px-5 py-4" data-testid="pack-review-panel">
<div>
<h3 className="text-base font-semibold text-(--text)">
This release contains several books
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-(--text)">{release.title}</span> ·{' '}
{describePackPlan(books, plan.ignored)}
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
Each book below is filed separately with its own title. Fix any titles before downloading
— the author and series come from the book you searched.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border border-(--border-muted) px-3 py-2">
<div>
<p className="text-sm font-medium text-(--text)">Treat as a single book</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Use this if the split is wrong and the files are really one audiobook.
</p>
</div>
<ToggleSwitch
checked={singleBook}
onChange={setSingleBook}
color="emerald"
ariaLabel="Treat as a single book"
disabled={isSubmitting}
/>
</div>
<div
className={`flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60 ${
singleBook ? 'pointer-events-none opacity-40' : ''
}`}
>
<div className="grid grid-cols-[minmax(0,1fr)_72px_72px_80px] gap-2 pb-1 text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
<span>Title</span>
<span>Series #</span>
<span>Year</span>
<span className="text-right">Files</span>
</div>
{books.map((book, index) => (
<div key={book.files[0] ?? index} className="py-2">
<div className="grid grid-cols-[minmax(0,1fr)_72px_72px_80px] items-center gap-2">
<input
type="text"
value={book.title}
onChange={(e) =>
onChange(updateReviewBook(books, index, { title: e.target.value }))
}
aria-label={`Title for book ${index + 1}`}
className={inputClassName}
disabled={isSubmitting}
/>
<input
type="text"
inputMode="decimal"
value={book.series_position ?? ''}
onChange={(e) =>
onChange(
updateReviewBook(books, index, {
series_position: parseSeriesPositionInput(e.target.value),
}),
)
}
aria-label={`Series position for book ${index + 1}`}
className={inputClassName}
disabled={isSubmitting}
/>
<input
type="text"
inputMode="numeric"
value={book.year ?? ''}
onChange={(e) => {
const parsed = parseSeriesPositionInput(e.target.value);
onChange(
updateReviewBook(books, index, {
year: parsed === null ? null : Math.trunc(parsed),
}),
);
}}
aria-label={`Year for book ${index + 1}`}
className={inputClassName}
disabled={isSubmitting}
/>
<button
type="button"
onClick={() => setExpandedFiles(expandedFiles === index ? null : index)}
className="hover-surface rounded-md px-2 py-1 text-right text-sm text-zinc-500 transition-colors dark:text-zinc-400"
aria-expanded={expandedFiles === index}
>
{book.files.length} {book.files.length === 1 ? 'file' : 'files'}
</button>
</div>
{expandedFiles === index && (
<ul className="mt-2 max-h-40 overflow-y-auto rounded-md bg-(--bg-soft) px-3 py-2 font-mono text-xs break-all text-zinc-600 dark:text-zinc-300">
{book.files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
)}
</div>
))}
</div>
{plan.ignored.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowIgnored(!showIgnored)}
className="text-xs text-zinc-500 underline-offset-2 hover:underline dark:text-zinc-400"
aria-expanded={showIgnored}
>
{plan.ignored.length} {plan.ignored.length === 1 ? 'file' : 'files'} ignored (not a book
format)
</button>
{showIgnored && (
<ul className="mt-2 max-h-32 overflow-y-auto rounded-md bg-(--bg-soft) px-3 py-2 font-mono text-xs break-all text-zinc-600 dark:text-zinc-300">
{plan.ignored.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
)}
</div>
)}
<div className="flex items-center justify-end gap-3 border-t border-(--border-muted) pt-4">
<button
type="button"
onClick={onBack}
disabled={isSubmitting}
className="hover-surface rounded-lg px-3 py-1.5 text-sm font-medium text-(--text) transition-colors disabled:opacity-50"
>
&larr; Back
</button>
<button
type="button"
onClick={() => void onConfirm(singleBook ? null : payloadBooks)}
disabled={!canConfirm}
className="rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Queuing…' : confirmLabel}
</button>
</div>
</div>
);
};
+112 -2
View File
@@ -7,6 +7,7 @@ import { useReleaseSearchSession } from '../hooks/releaseModal/useReleaseSearchS
import { useTabIndicator } from '../hooks/ui/useTabIndicator';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { inspectRelease } from '../services/api';
import type {
Book,
Release,
@@ -18,6 +19,8 @@ import type {
LeadingCellConfig,
ContentType,
RequestPolicyMode,
PackBook,
PackPlan,
} from '../types';
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
@@ -29,7 +32,9 @@ import {
buildLanguageNormalizer,
} from '../utils/languageFilters';
import { getNestedValue, toComparableText, toStringValue } from '../utils/objectHelpers';
import { toBookPlanPayload } from '../utils/packReview';
import { getReleaseFormats } from '../utils/releaseFormats';
import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from '../utils/releasePayload';
import {
getBookTitleCandidates,
getBookAuthorCandidates,
@@ -50,6 +55,7 @@ import { BookTargetDropdown } from './BookTargetDropdown';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { PackReviewPanel } from './PackReviewPanel';
import { ReleaseCell } from './ReleaseCell';
// Combined mode configuration for the ReleaseModal
@@ -140,7 +146,12 @@ const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
interface ReleaseModalProps {
book: Book | null;
onClose: () => void;
onDownload: (book: Book, release: Release, contentType: ContentType) => Promise<void>;
onDownload: (
book: Book,
release: Release,
contentType: ContentType,
options?: ReleaseDownloadOptions,
) => Promise<void>;
onRequestRelease?: (book: Book, release: Release, contentType: ContentType) => Promise<void>;
onRequestBook?: (book: Book, contentType: ContentType) => Promise<void>;
getPolicyModeForSource?: (source: string, contentType: ContentType) => RequestPolicyMode;
@@ -762,6 +773,15 @@ const ReleaseModalSession = ({
: supportedFormats;
const [isRequestingBook, setIsRequestingBook] = useState(false);
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null);
// Multi-book packs: `multiBook` is the manual header toggle (heuristic split for
// releases we can't inspect); `packReview` holds an inspected pack awaiting approval.
const [multiBook, setMultiBook] = useState(false);
const [packReview, setPackReview] = useState<{
release: Release;
plan: PackPlan;
books: PackBook[];
} | null>(null);
const [packSubmitting, setPackSubmitting] = useState(false);
const isCombinedMode = combinedMode != null;
const combinedPhase = combinedMode?.phase ?? null;
const combinedStepLabel = combinedMode?.stepLabel ?? '';
@@ -1196,7 +1216,30 @@ const ReleaseModalSession = ({
const mode = getReleaseActionMode(release);
if (mode === 'download') {
await onDownload(book, release, contentType);
// Look at the release's files before queueing so a whole-series pack can be
// reviewed and filed as separate books instead of one mangled item.
let inspected = false;
let plan: PackPlan | null = null;
try {
const inspection = await inspectRelease(
buildReleaseDownloadPayload(book, release, contentType),
);
inspected = inspection.inspected;
plan = inspection.plan;
} catch (error) {
console.error('Release inspection failed:', error);
}
if (inspected && plan?.is_pack) {
setPackReview({ release, plan, books: plan.books });
return;
}
if (!inspected && !multiBook) {
onShowToast?.(
"Couldn't check this release's files before download. If it contains several books, turn on the multi-book pack toggle first.",
'info',
);
}
await onDownload(book, release, contentType, multiBook ? { multiBook: true } : {});
handleClose();
return;
}
@@ -1215,9 +1258,32 @@ const ReleaseModalSession = ({
onRequestRelease,
contentType,
handleClose,
multiBook,
onShowToast,
],
);
const handlePackConfirm = useCallback(
async (books: PackBook[] | null): Promise<void> => {
if (!book || !packReview) {
return;
}
setPackSubmitting(true);
try {
await onDownload(
book,
packReview.release,
contentType,
books ? { multiBook: true, bookPlan: toBookPlanPayload(books) } : {},
);
handleClose();
} finally {
setPackSubmitting(false);
}
},
[book, packReview, onDownload, contentType, handleClose],
);
const titleId = `release-modal-title-${book.id}`;
const providerDisplay =
book.provider_display_name ||
@@ -1720,6 +1786,37 @@ const ReleaseModalSession = ({
</div>
<div className="flex items-center gap-3 pr-1 pl-2">
{/* Multi-book pack toggle (fallback for releases that can't be inspected) */}
{!isCombinedMode && (
<button
type="button"
onClick={() => setMultiBook((prev) => !prev)}
className={`hover-surface relative rounded-full p-2.5 text-zinc-500 transition-colors dark:text-zinc-400 ${
multiBook ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Multi-book pack"
aria-pressed={multiBook}
title="Multi-book pack: file each subfolder (or each file) as a separate book. Only needed when a release can't be inspected before download."
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3"
/>
</svg>
{multiBook && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-500" />
)}
</button>
)}
{/* Manual query button */}
<button
type="button"
@@ -2120,6 +2217,19 @@ const ReleaseModalSession = ({
{/* Release list content */}
<div className="min-h-[200px]">
{(() => {
if (packReview) {
return (
<PackReviewPanel
release={packReview.release}
plan={packReview.plan}
books={packReview.books}
onChange={(books) => setPackReview({ ...packReview, books })}
onBack={() => setPackReview(null)}
onConfirm={handlePackConfirm}
isSubmitting={packSubmitting}
/>
);
}
if (sourcesLoading) {
return <ReleaseSkeleton />;
}
+14
View File
@@ -12,6 +12,8 @@ import type {
RequestSubmissionResult,
MetadataProvidersResponse,
MetadataSearchConfig,
PackBook,
InspectReleaseResponse,
} from '../types';
import type {
ActionResult,
@@ -510,6 +512,18 @@ export type DownloadReleasePayload = {
language?: string; // Release language code, for the {Language} naming variable
search_author?: string;
search_mode?: 'direct' | 'universal';
multi_book?: boolean; // Split a multi-book pack into one book per subfolder/file
book_plan?: PackBook[]; // The split the user approved before download
};
/** Inspect a release's file list before download (same body as downloadRelease). */
export const inspectRelease = async (
release: DownloadReleasePayload,
): Promise<InspectReleaseResponse> => {
return fetchJSON<InspectReleaseResponse>(`${API_BASE}/releases/inspect`, {
method: 'POST',
body: JSON.stringify(release),
});
};
export const downloadRelease = async (
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import type { PackBook } from '../types';
import {
describePackPlan,
parseSeriesPositionInput,
toBookPlanPayload,
updateReviewBook,
} from '../utils/packReview';
const books: PackBook[] = [
{ title: 'Leviathan Wakes', series_position: 1, year: 2011, files: ['a.m4b'] },
{ title: 'Caliban’s War', series_position: 2, year: 2012, files: ['b.m4b', 'b2.m4b'] },
];
describe('packReview.updateReviewBook', () => {
it('replaces one book without touching the others', () => {
const next = updateReviewBook(books, 1, { title: 'Caliban’s War (Unabridged)' });
expect(next[0]).toBe(books[0]);
expect(next[1]).toEqual({ ...books[1], title: 'Caliban’s War (Unabridged)' });
expect(books[1].title).toBe('Caliban’s War');
});
});
describe('packReview.parseSeriesPositionInput', () => {
it('accepts whole and fractional positions', () => {
expect(parseSeriesPositionInput('3')).toBe(3);
expect(parseSeriesPositionInput('2.5')).toBe(2.5);
});
it('treats blank or junk as no position', () => {
expect(parseSeriesPositionInput('')).toBeNull();
expect(parseSeriesPositionInput('abc')).toBeNull();
});
});
describe('packReview.toBookPlanPayload', () => {
it('trims titles, drops books without a title, and keeps file lists', () => {
const edited = updateReviewBook(books, 0, { title: ' ' });
expect(toBookPlanPayload(edited)).toEqual([
{ title: 'Caliban’s War', series_position: 2, year: 2012, files: ['b.m4b', 'b2.m4b'] },
]);
});
});
describe('packReview.describePackPlan', () => {
it('summarises books, files and ignored sidecars', () => {
expect(describePackPlan(books, ['a.txt', 'cover.jpg'])).toBe(
'2 books · 3 files · 2 files ignored',
);
expect(describePackPlan([books[0]], [])).toBe('1 book · 1 file');
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import type { Book, Release } from '../types';
import { buildReleaseDownloadPayload } from '../utils/releasePayload';
const book: Book = {
id: 'hc-1',
title: 'Drive',
author: 'James S. A. Corey',
year: '2012',
preview: 'https://img/drive.jpg',
series_name: 'The Expanse',
series_position: 2.6,
subtitle: 'An Expanse Short Story',
provider: 'hardcover',
provider_id: 'hc-1',
source: 'direct_download',
};
const release: Release = {
source: 'audiobookbay',
source_id: 'abb-1',
title: 'James S. A. Corey - The Expanse Complete 2.0',
format: 'm4b',
language: 'en',
download_url: 'https://audiobookbay.lu/abss/expanse/',
};
describe('buildReleaseDownloadPayload', () => {
it('describes the searched book and the chosen release', () => {
const payload = buildReleaseDownloadPayload(book, release, 'audiobook');
expect(payload).toMatchObject({
source: 'audiobookbay',
source_id: 'abb-1',
title: 'Drive',
author: 'James S. A. Corey',
series_name: 'The Expanse',
series_position: 2.6,
language: 'en',
content_type: 'audiobook',
});
expect(payload.multi_book).toBeUndefined();
expect(payload.book_plan).toBeUndefined();
});
it('uses the release title and author for manual books', () => {
const manual: Book = { ...book, provider: 'manual', title: 'ignored' };
const withAuthor = { ...release, extra: { author: 'Release Author' } };
const payload = buildReleaseDownloadPayload(manual, withAuthor, 'audiobook');
expect(payload.title).toBe(release.title);
expect(payload.author).toBe('Release Author');
});
it('flags a manual multi-book pack', () => {
const payload = buildReleaseDownloadPayload(book, release, 'audiobook', { multiBook: true });
expect(payload.multi_book).toBe(true);
expect(payload.book_plan).toBeUndefined();
});
it('attaches the approved book plan', () => {
const plan = [{ title: 'Leviathan Wakes', series_position: 1, year: 2011, files: ['a.m4b'] }];
const payload = buildReleaseDownloadPayload(book, release, 'audiobook', {
multiBook: true,
bookPlan: plan,
});
expect(payload.multi_book).toBe(true);
expect(payload.book_plan).toEqual(plan);
});
});
+21
View File
@@ -460,6 +460,27 @@ export interface SourceSearchInfo {
}
// Response from /api/releases endpoint
/** One book split out of a multi-book pack release, files as release-relative paths. */
export interface PackBook {
title: string;
series_position: number | null;
year: number | null;
files: string[];
}
export interface PackPlan {
is_pack: boolean;
books: PackBook[];
ignored: string[];
}
export interface InspectReleaseResponse {
inspected: boolean;
reason: string | null;
files: { path: string; size: number | null }[];
plan: PackPlan | null;
}
export interface ReleasesResponse {
releases: Release[];
book: {
+39
View File
@@ -0,0 +1,39 @@
import type { PackBook } from '../types';
/** Return a copy of `books` with one entry patched; the input is not mutated. */
export function updateReviewBook(
books: PackBook[],
index: number,
patch: Partial<PackBook>,
): PackBook[] {
return books.map((book, i) => (i === index ? { ...book, ...patch } : book));
}
/** Parse a series-position text field: "3" → 3, "2.5" → 2.5, blank/junk → null. */
export function parseSeriesPositionInput(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
/** The plan sent with the download: trimmed titles, untitled books dropped. */
export function toBookPlanPayload(books: PackBook[]): PackBook[] {
return books
.map((book) => ({ ...book, title: book.title.trim() }))
.filter((book) => book.title.length > 0 && book.files.length > 0);
}
function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/** "2 books · 3 files · 2 files ignored" */
export function describePackPlan(books: PackBook[], ignored: string[]): string {
const fileCount = books.reduce((sum, book) => sum + book.files.length, 0);
const parts = [plural(books.length, 'book'), plural(fileCount, 'file')];
if (ignored.length > 0) {
parts.push(`${plural(ignored.length, 'file')} ignored`);
}
return parts.join(' · ');
}
+55
View File
@@ -0,0 +1,55 @@
import type { DownloadReleasePayload } from '../services/api';
import type { Book, ContentType, PackBook, Release } from '../types';
export interface ReleaseDownloadOptions {
/** Ask post-processing to split the release into one book per subfolder/file. */
multiBook?: boolean;
/** The split the user approved in the pack review panel. */
bookPlan?: PackBook[];
}
/** Build the body for /api/releases/download (and /api/releases/inspect). */
export function buildReleaseDownloadPayload(
book: Book,
release: Release,
releaseContentType: ContentType,
options: ReleaseDownloadOptions = {},
): DownloadReleasePayload {
const isManual = book.provider === 'manual';
const releasePreview =
typeof release.extra?.preview === 'string' ? release.extra.preview : undefined;
const releaseAuthor =
typeof release.extra?.author === 'string' ? release.extra.author : undefined;
const payload: DownloadReleasePayload = {
source: release.source,
source_id: release.source_id,
title: isManual ? release.title : book.title,
author: isManual ? releaseAuthor || '' : book.author,
year: book.year,
format: release.format,
size: release.size,
size_bytes: release.size_bytes,
download_url: release.download_url,
protocol: release.protocol,
indexer: release.indexer,
seeders: release.seeders,
extra: release.extra,
preview: isManual ? releasePreview || undefined : book.preview,
content_type: releaseContentType,
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
// From the release, never the book: book.language is the provider's
// canonical edition, which would mislabel a translated release.
language: release.language ?? undefined,
};
if (options.multiBook || options.bookPlan) {
payload.multi_book = true;
}
if (options.bookPlan) {
payload.book_plan = options.bookPlan;
}
return payload;
}