mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 19:51:05 +01:00
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:
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user