feat(packs): let users choose which files, books and formats to download (#1480)

## Summary
- **Skip files or books in a multi-book pack.** The pack review panel
gets a switch per file and a "Skip this book" switch per book, using the
shared `ToggleSwitch`. A partly skipped book shows e.g. "3 of 5 files"
in the Files column. A fully skipped book is greyed out.
- **Choose formats for a single book.** A new "Choose formats" header
toggle, next to the multi-book one, opens a format picker for a release
that has several book formats (EPUB, MOBI, PDF and so on). It has one
switch per format with file count and size. Releases with one format, or
that can't be inspected, download as before.
- **Skipped files are sent as `ignored_files`.** They are stored on the
task (including restart-safe retry) and removed before grouping and
transfer. This matters for packs because files missing from `book_plan`
would otherwise be filed by the heuristic fallback. Ignored entries
match by release-relative path, then path suffix, then unique basename.
- A pack that ends up with one book after skips keeps the titles you
reviewed instead of falling back to the searched title.

## Screenshots
<img width="1277" height="413" alt="Screenshot 2026-10-19 at 09-39-28
Shelfmark"
src="https://github.com/user-attachments/assets/c005ca4b-3dac-45dd-b2ff-d02fc0f9fcc9"
/>
<img width="1277" height="1210" alt="Screenshot 2026-10-19 at 09-40-23
Shelfmark"
src="https://github.com/user-attachments/assets/2145f3ba-e5fc-4635-9364-1790feb0c8b8"
/>
<img width="1277" height="748" alt="Screenshot 2026-10-19 at 09-39-03
Shelfmark"
src="https://github.com/user-attachments/assets/39023fac-70e3-46d8-af89-1160ebd6e415"
/>


## Test plan
- [x] `pytest` pack tests (new `drop_ignored_files` cases added)
- [x] `vitest`, `tsc`, `oxlint`, `oxfmt`, `ruff`
- [x] Manual check in the UI with a real multi-book pack

🤖 (mostly) Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
amura11andClaude Sonnet 5.5 authored and GitHub committed 2026-10-20 02:03:05 -04:00
1 parent 3c5cfcb77d
commit 51f32b374d
16 files changed
+611 -71

No files matched your search

+2
View File
@@ -143,6 +143,8 @@ class DownloadTask:
# `multi_book` asks post-processing to split heuristically when no plan exists.
multi_book: bool = False
book_plan: list[dict[str, Any]] | None = None
# Release-relative paths the user chose to skip in the pack review.
ignored_files: list[str] | None = None
# User association (multi-user support)
user_id: int | None = None # DB user ID who queued this download
+14
View File
@@ -286,6 +286,9 @@ def queue_release(
language = release_data.get("language") or extra.get("language")
multi_book = bool(release_data.get("multi_book") or extra.get("multi_book"))
book_plan = _normalize_book_plan(release_data.get("book_plan") or extra.get("book_plan"))
ignored_files = _normalize_ignored_files(
release_data.get("ignored_files") or extra.get("ignored_files")
)
books_output_mode = (
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
@@ -325,6 +328,7 @@ def queue_release(
language=language,
multi_book=multi_book or book_plan is not None,
book_plan=book_plan,
ignored_files=ignored_files,
search_mode=search_mode,
output_mode=output_mode,
output_args=output_args,
@@ -470,6 +474,14 @@ def can_retry_download_task(
return _has_staged_retry_source(task)
def _normalize_ignored_files(value: object) -> list[str] | None:
"""Keep only non-empty string paths from a user-ignored file list."""
if not isinstance(value, list):
return None
files = [f for f in value if isinstance(f, str) and f.strip()]
return files or None
def _normalize_book_plan(value: object) -> list[dict[str, Any]] | None:
"""Keep only well-formed pack books: a title plus a non-empty list of file paths."""
if not isinstance(value, list):
@@ -529,6 +541,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"search_mode": search_mode,
"multi_book": bool(getattr(task, "multi_book", False)),
"book_plan": _normalize_book_plan(getattr(task, "book_plan", None)),
"ignored_files": _normalize_ignored_files(getattr(task, "ignored_files", None)),
"output_mode": getattr(task, "output_mode", None),
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
"user_id": getattr(task, "user_id", None),
@@ -591,6 +604,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
search_mode=search_mode,
multi_book=bool(payload.get("multi_book", False)),
book_plan=_normalize_book_plan(payload.get("book_plan")),
ignored_files=_normalize_ignored_files(payload.get("ignored_files")),
output_mode=normalize_optional_text(payload.get("output_mode")),
output_args=dict(output_args) if isinstance(output_args, dict) else {},
user_id=normalize_positive_int(payload.get("user_id")),
+5 -2
View File
@@ -96,6 +96,7 @@ def process_folder_output(
preserve_source_on_failure: bool = False,
) -> str | None:
"""Post-process download to the configured folder destination."""
from shelfmark.download.postprocess.packs import drop_ignored_files
from shelfmark.download.postprocess.pipeline import (
CustomScriptContext,
CustomScriptTransferSummary,
@@ -262,10 +263,12 @@ def process_folder_output(
)
pack_groups = resolve_book_groups(
task, prepared.files, organization_mode=plan.organization_mode
task,
drop_ignored_files(prepared.files, task.ignored_files),
organization_mode=plan.organization_mode,
)
if pack_groups is not None:
message = f"Complete ({len(pack_groups)} books, {len(final_paths)} files)"
message = f"Complete ({len(pack_groups)} {'book' if len(pack_groups) == 1 else 'books'}, {len(final_paths)} files)"
elif len(final_paths) == 1:
message = "Complete"
else:
+28
View File
@@ -357,6 +357,34 @@ def group_files_into_books(
]
def drop_ignored_files(book_files: list[Path], ignored: list[str] | None) -> list[Path]:
"""Remove files the user chose to skip in the pack review.
Ignored entries are release-relative paths. A file matches by relative path first,
then by one path being a suffix of the other (the root can shift after extraction),
then by basename when that basename is unique among the files.
"""
if not book_files or not ignored:
return book_files
_root, rel_by_path = _relative_paths(book_files)
wanted = {i.replace("\\", "/").lstrip("./") for i in ignored if i and i.strip()}
name_counts: dict[str, int] = {}
for path in book_files:
name_counts[path.name] = name_counts.get(path.name, 0) + 1
def is_ignored(path: Path) -> bool:
rel = rel_by_path[path]
if rel in wanted:
return True
if any(w.endswith("/" + rel) or rel.endswith("/" + w) for w in wanted):
return True
return name_counts[path.name] == 1 and any(
PurePosixPath(w).name == path.name for w in wanted
)
return [p for p in book_files if not is_ignored(p)]
def match_plan_to_files(
plan: list[PackBook],
book_files: list[Path],
+13 -2
View File
@@ -31,7 +31,13 @@ from shelfmark.download.postprocess.policy import (
get_word_separator,
)
from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files
from .packs import (
BookGroup,
PackBook,
drop_ignored_files,
group_files_into_books,
match_plan_to_files,
)
from .scan import collect_directory_files, scan_directory_tree
from .types import TransferPlan
from .workspace import safe_cleanup_path
@@ -198,6 +204,7 @@ def transfer_book_files(
source_root: Path | None = None,
) -> tuple[list[Path], str | None, dict[str, int]]:
"""Transfer discovered book files into their final destination layout."""
book_files = drop_ignored_files(book_files, task.ignored_files)
if not book_files:
return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0}
@@ -356,7 +363,10 @@ def resolve_book_groups(
groups = group_files_into_books(
book_files, series_name=task.series_name, author_name=task.author
)
return groups if len(groups) > 1 else None
# An approved plan that lost books to the user's skips still carries the titles they
# reviewed, so a single remaining group keeps them instead of the searched title.
keeps_plan = bool(task.book_plan and task.ignored_files)
return groups if len(groups) > 1 or (keeps_plan and groups) else None
def _transfer_book_groups(
@@ -388,6 +398,7 @@ def _transfer_book_groups(
series_position=group.series_position,
multi_book=False,
book_plan=None,
ignored_files=None,
)
paths, error, op_counts = transfer_book_files(
group.files,
@@ -0,0 +1,113 @@
import { useState } from 'react';
import type { Release } from '../types';
import { formatBytes, ignoredForFormats, type FormatGroup } from '../utils/formatReview';
import { ToggleSwitch } from './shared/ToggleSwitch';
interface FormatReviewPanelProps {
release: Release;
groups: FormatGroup[];
onBack: () => void;
/** Release-relative paths of the files in every format the user switched off. */
onConfirm: (ignoredFiles: string[]) => Promise<void>;
isSubmitting: boolean;
}
export const FormatReviewPanel = ({
release,
groups,
onBack,
onConfirm,
isSubmitting,
}: FormatReviewPanelProps) => {
const [selected, setSelected] = useState<ReadonlySet<string>>(
new Set(groups.map((group) => group.format)),
);
const [expanded, setExpanded] = useState<string | null>(null);
const toggleFormat = (format: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(format)) next.delete(format);
else next.add(format);
return next;
});
};
const fileCount = groups
.filter((group) => selected.has(group.format))
.reduce((sum, group) => sum + group.files.length, 0);
const canConfirm = !isSubmitting && selected.size > 0;
return (
<div className="flex flex-col gap-4 px-5 py-4" data-testid="format-review-panel">
<div>
<h3 className="text-base font-semibold text-(--text)">Choose formats to download</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-(--text)">{release.title}</span> comes in several
formats. Switch off any you don&rsquo;t want; they won&rsquo;t be filed.
</p>
</div>
<div className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
{groups.map((group) => {
const included = selected.has(group.format);
const size = formatBytes(group.totalBytes);
return (
<div key={group.format} className={`py-2 ${included ? '' : 'opacity-50'}`}>
<div className="flex items-center gap-3">
<ToggleSwitch
checked={included}
onChange={() => toggleFormat(group.format)}
color="emerald"
ariaLabel={`Include ${group.format || 'unknown'} format`}
disabled={isSubmitting}
/>
<span className="w-16 text-sm font-medium text-(--text) uppercase">
{group.format || 'other'}
</span>
<button
type="button"
onClick={() => setExpanded(expanded === group.format ? null : group.format)}
className="hover-surface ml-auto rounded-md px-2 py-1 text-sm text-zinc-500 transition-colors dark:text-zinc-400"
aria-expanded={expanded === group.format}
>
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
{size && ` · ${size}`}
</button>
</div>
{expanded === group.format && (
<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">
{group.files.map((file) => (
<li key={file.path}>{file.path}</li>
))}
</ul>
)}
</div>
);
})}
</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(ignoredForFormats(groups, selected))}
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…'
: `Download ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`}
</button>
</div>
</div>
);
};
+122 -65
View File
@@ -2,8 +2,10 @@ import { useState } from 'react';
import type { PackBook, PackPlan, Release } from '../types';
import {
applyIgnoredFiles,
describePackPlan,
parseSeriesPositionInput,
setBookIgnored,
toBookPlanPayload,
updateReviewBook,
} from '../utils/packReview';
@@ -16,7 +18,7 @@ interface PackReviewPanelProps {
onChange: (books: PackBook[]) => void;
onBack: () => void;
/** `null` means "treat the whole release as one book". */
onConfirm: (books: PackBook[] | null) => Promise<void>;
onConfirm: (books: PackBook[] | null, ignoredFiles?: string[]) => Promise<void>;
isSubmitting: boolean;
}
@@ -35,8 +37,18 @@ export const PackReviewPanel = ({
const [singleBook, setSingleBook] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<number | null>(null);
const [showIgnored, setShowIgnored] = useState(false);
const [userIgnored, setUserIgnored] = useState<ReadonlySet<string>>(new Set());
const payloadBooks = toBookPlanPayload(books);
const toggleFile = (file: string) => {
setUserIgnored((prev) => {
const next = new Set(prev);
if (next.has(file)) next.delete(file);
else next.add(file);
return next;
});
};
const payloadBooks = toBookPlanPayload(applyIgnoredFiles(books, userIgnored));
const canConfirm = !isSubmitting && (singleBook || payloadBooks.length > 0);
const confirmLabel = singleBook
? 'Download as one book'
@@ -50,7 +62,10 @@ export const PackReviewPanel = ({
</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)}
{describePackPlan(applyIgnoredFiles(books, userIgnored), [
...plan.ignored,
...userIgnored,
])}
</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
@@ -85,68 +100,110 @@ export const PackReviewPanel = ({
<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>
{books.map((book, index) => {
const skipped = book.files.every((file) => userIgnored.has(file));
const ignoredCount = book.files.filter((file) => userIgnored.has(file)).length;
const partiallyIgnored = !skipped && ignoredCount > 0;
const fileNoun = book.files.length === 1 ? 'file' : 'files';
return (
<div
key={book.files[0] ?? index}
className={`py-2 ${skipped ? 'opacity-50' : ''}`}
data-skipped={skipped}
>
<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}
>
{partiallyIgnored ? (
<span
className="text-amber-600 dark:text-amber-400"
title={`${ignoredCount} of ${book.files.length} ${fileNoun} skipped`}
>
{book.files.length - ignoredCount} of {book.files.length} {fileNoun}
</span>
) : (
`${book.files.length} ${fileNoun}`
)}
</button>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<ToggleSwitch
checked={skipped}
onChange={(checked) => setUserIgnored(setBookIgnored(userIgnored, book, checked))}
color="emerald"
ariaLabel={`Skip book ${index + 1}`}
disabled={isSubmitting}
/>
Skip this book
</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}>
<div className="flex items-center gap-2 py-0.5">
<ToggleSwitch
checked={!userIgnored.has(file)}
onChange={() => toggleFile(file)}
color="emerald"
ariaLabel={`Include ${file}`}
disabled={isSubmitting}
/>
<span className={userIgnored.has(file) ? 'line-through opacity-60' : ''}>
{file}
</span>
</div>
</li>
))}
</ul>
)}
</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 && (
@@ -181,7 +238,7 @@ export const PackReviewPanel = ({
</button>
<button
type="button"
onClick={() => void onConfirm(singleBook ? null : payloadBooks)}
onClick={() => void onConfirm(singleBook ? null : payloadBooks, [...userIgnored])}
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"
>
+84 -2
View File
@@ -20,11 +20,13 @@ import type {
ContentType,
RequestPolicyMode,
PackBook,
InspectReleaseResponse,
PackPlan,
} from '../types';
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { groupFilesByFormat, type FormatGroup } from '../utils/formatReview';
import {
LANGUAGE_OPTION_DEFAULT,
getLanguageFilterValues,
@@ -55,6 +57,7 @@ import { BookDownloadButton } from './BookDownloadButton';
import { BookTargetDropdown } from './BookTargetDropdown';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { FormatReviewPanel } from './FormatReviewPanel';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { PackReviewPanel } from './PackReviewPanel';
import { ReleaseCell } from './ReleaseCell';
@@ -788,6 +791,13 @@ const ReleaseModalSession = ({
books: PackBook[];
} | null>(null);
const [packSubmitting, setPackSubmitting] = useState(false);
// Format picker: `chooseFormats` is the header toggle; `formatReview` holds an inspected
// release with several formats awaiting the user's selection.
const [chooseFormats, setChooseFormats] = useState(false);
const [formatReview, setFormatReview] = useState<{
release: Release;
groups: FormatGroup[];
} | null>(null);
const isCombinedMode = combinedMode != null;
const combinedPhase = combinedMode?.phase ?? null;
const combinedStepLabel = combinedMode?.stepLabel ?? '';
@@ -1233,6 +1243,7 @@ const ReleaseModalSession = ({
// reviewed and filed as separate books instead of one mangled item.
let inspected = false;
let plan: PackPlan | null = null;
let inspectedFiles: InspectReleaseResponse['files'] = [];
let reason: string | null = null;
try {
const inspection = await inspectRelease(
@@ -1240,6 +1251,7 @@ const ReleaseModalSession = ({
);
inspected = inspection.inspected;
plan = inspection.plan;
inspectedFiles = inspection.files;
reason = inspection.reason;
} catch (error) {
console.error('Release inspection failed:', error);
@@ -1248,6 +1260,16 @@ const ReleaseModalSession = ({
setPackReview({ release, plan, books: plan.books });
return;
}
if (inspected && chooseFormats && plan) {
const groups = groupFilesByFormat(
inspectedFiles,
new Set(plan.books.flatMap((planBook) => planBook.files)),
);
if (groups.length > 1) {
setFormatReview({ release, groups });
return;
}
}
// Not a pack (or couldn't be inspected): queue exactly as before. A release we
// couldn't inspect might still be an unnoticed pack, so leave a console breadcrumb
// rather than interrupting the user; the multi-book toggle forces the split.
@@ -1278,11 +1300,29 @@ const ReleaseModalSession = ({
contentType,
handleClose,
multiBook,
chooseFormats,
],
);
const handleFormatConfirm = useCallback(
async (ignoredFiles: string[]): Promise<void> => {
if (!book || !formatReview) {
return;
}
setPackSubmitting(true);
try {
await onDownload(book, formatReview.release, contentType, { ignoredFiles });
handleClose();
} finally {
setPackSubmitting(false);
}
},
// oxlint-disable-next-line react/memo-dependencies
[book, formatReview, onDownload, contentType, handleClose],
);
const handlePackConfirm = useCallback(
async (books: PackBook[] | null): Promise<void> => {
async (books: PackBook[] | null, ignoredFiles: string[] = []): Promise<void> => {
if (!book || !packReview) {
return;
}
@@ -1292,7 +1332,7 @@ const ReleaseModalSession = ({
book,
packReview.release,
contentType,
books ? { multiBook: true, bookPlan: toBookPlanPayload(books) } : {},
books ? { multiBook: true, bookPlan: toBookPlanPayload(books), ignoredFiles } : {},
);
handleClose();
} finally {
@@ -1871,6 +1911,37 @@ const ReleaseModalSession = ({
</button>
)}
{/* Format picker toggle: choose which formats of a release to download */}
{!isCombinedMode && (
<button
type="button"
onClick={() => setChooseFormats((prev) => !prev)}
className={`hover-surface relative rounded-full p-2.5 text-zinc-500 transition-colors dark:text-zinc-400 ${
chooseFormats ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Choose formats"
aria-pressed={chooseFormats}
title="Choose formats: when a release has several formats, pick which ones to 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="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
{chooseFormats && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-500" />
)}
</button>
)}
{/* Manual query button */}
<button
type="button"
@@ -2271,6 +2342,17 @@ const ReleaseModalSession = ({
{/* Release list content */}
<div className="min-h-[200px]">
{(() => {
if (formatReview) {
return (
<FormatReviewPanel
release={formatReview.release}
groups={formatReview.groups}
onBack={() => setFormatReview(null)}
onConfirm={handleFormatConfirm}
isSubmitting={packSubmitting}
/>
);
}
if (packReview) {
return (
<PackReviewPanel
+1
View File
@@ -562,6 +562,7 @@ export type DownloadReleasePayload = {
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
ignored_files?: string[]; // Pack files the user chose to skip
};
/** Inspect a release's file list before download (same body as downloadRelease). */
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import {
fileFormat,
formatBytes,
groupFilesByFormat,
ignoredForFormats,
type ReviewFile,
} from '../utils/formatReview';
const files: ReviewFile[] = [
{ path: 'Book/Book.epub', size: 1000 },
{ path: 'Book/Book.mobi', size: 2000 },
{ path: 'Book/Book.pdf', size: null },
{ path: 'Book/Book2.epub', size: 500 },
{ path: 'Book/cover.jpg', size: 10 },
];
const bookFiles = new Set(files.filter((f) => !f.path.endsWith('.jpg')).map((f) => f.path));
describe('formatReview.fileFormat', () => {
it('lowercases the extension and handles missing ones', () => {
expect(fileFormat('a/B.EPUB')).toBe('epub');
expect(fileFormat('a\\b\\c.m4b')).toBe('m4b');
expect(fileFormat('README')).toBe('');
expect(fileFormat('.hidden')).toBe('');
});
});
describe('formatReview.groupFilesByFormat', () => {
it('groups only book files, biggest group first, summing known sizes', () => {
const groups = groupFilesByFormat(files, bookFiles);
expect(groups.map((g) => g.format)).toEqual(['epub', 'mobi', 'pdf']);
expect(groups[0].totalBytes).toBe(1500);
expect(groups[2].totalBytes).toBeNull();
});
});
describe('formatReview.ignoredForFormats', () => {
it('returns every file of unselected formats', () => {
const groups = groupFilesByFormat(files, bookFiles);
expect(ignoredForFormats(groups, new Set(['epub']))).toEqual([
'Book/Book.mobi',
'Book/Book.pdf',
]);
expect(ignoredForFormats(groups, new Set(['epub', 'mobi', 'pdf']))).toEqual([]);
});
});
describe('formatReview.formatBytes', () => {
it('formats sizes and tolerates unknown', () => {
expect(formatBytes(null)).toBe('');
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
});
});
+23
View File
@@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest';
import type { PackBook } from '../types';
import {
applyIgnoredFiles,
describePackPlan,
parseSeriesPositionInput,
setBookIgnored,
toBookPlanPayload,
updateReviewBook,
} from '../utils/packReview';
@@ -51,3 +53,24 @@ describe('packReview.describePackPlan', () => {
expect(describePackPlan([books[0]], [])).toBe('1 book · 1 file');
});
});
describe('packReview.applyIgnoredFiles', () => {
it('removes ignored files and drops books left empty', () => {
const result = applyIgnoredFiles(books, new Set(['b2.m4b', 'a.m4b']));
expect(result).toEqual([{ ...books[1], files: ['b.m4b'] }]);
});
it('returns the input untouched when nothing is ignored', () => {
expect(applyIgnoredFiles(books, new Set())).toBe(books);
});
});
describe('packReview.setBookIgnored', () => {
it('adds and removes every file of a book without mutating the input', () => {
const start = new Set<string>();
const added = setBookIgnored(start, books[1], true);
expect([...added].toSorted()).toEqual(['b.m4b', 'b2.m4b']);
expect(start.size).toBe(0);
expect(setBookIgnored(added, books[1], false).size).toBe(0);
});
});
+57
View File
@@ -0,0 +1,57 @@
export interface ReviewFile {
path: string;
size: number | null;
}
export interface FormatGroup {
/** Lowercase extension without the dot, e.g. "epub". */
format: string;
files: ReviewFile[];
/** Sum of known file sizes in bytes; null when no file reports a size. */
totalBytes: number | null;
}
/** The lowercase extension of a release-relative path ("" when it has none). */
export function fileFormat(path: string): string {
const name = path.split(/[\\/]/).pop() ?? '';
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
/** Group the release's book files by format, largest group first. */
export function groupFilesByFormat(
files: ReviewFile[],
bookFiles: ReadonlySet<string>,
): FormatGroup[] {
const groups = new Map<string, FormatGroup>();
for (const file of files) {
if (!bookFiles.has(file.path)) continue;
const format = fileFormat(file.path);
const group = groups.get(format) ?? { format, files: [], totalBytes: null };
group.files.push(file);
if (file.size !== null) group.totalBytes = (group.totalBytes ?? 0) + file.size;
groups.set(format, group);
}
return [...groups.values()].toSorted(
(a, b) => b.files.length - a.files.length || a.format.localeCompare(b.format),
);
}
/** Every file belonging to a format that is not selected. */
export function ignoredForFormats(groups: FormatGroup[], selected: ReadonlySet<string>): string[] {
return groups.filter((g) => !selected.has(g.format)).flatMap((g) => g.files.map((f) => f.path));
}
/** "1.5 MB", "820 KB"; empty string for an unknown size. */
export function formatBytes(bytes: number | null): string {
if (bytes === null) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const digits = unit === 0 || value >= 100 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
+22
View File
@@ -24,6 +24,28 @@ export function toBookPlanPayload(books: PackBook[]): PackBook[] {
.filter((book) => book.title.length > 0 && book.files.length > 0);
}
/** Books with the user-ignored files removed; books left with no files are dropped. */
export function applyIgnoredFiles(books: PackBook[], ignored: ReadonlySet<string>): PackBook[] {
if (ignored.size === 0) return books;
return books
.map((book) => ({ ...book, files: book.files.filter((file) => !ignored.has(file)) }))
.filter((book) => book.files.length > 0);
}
/** Add or remove every file of a book from the ignored set (returns a new set). */
export function setBookIgnored(
ignored: ReadonlySet<string>,
book: PackBook,
shouldIgnore: boolean,
): Set<string> {
const next = new Set(ignored);
for (const file of book.files) {
if (shouldIgnore) next.add(file);
else next.delete(file);
}
return next;
}
function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
+5
View File
@@ -6,6 +6,8 @@ export interface ReleaseDownloadOptions {
multiBook?: boolean;
/** The split the user approved in the pack review panel. */
bookPlan?: PackBook[];
/** Files from the pack the user chose to skip (release-relative paths). */
ignoredFiles?: string[];
}
/** Download count for a release, preferring what the release itself reports. */
@@ -69,5 +71,8 @@ export function buildReleaseDownloadPayload(
if (options.bookPlan) {
payload.book_plan = options.bookPlan;
}
if (options.ignoredFiles && options.ignoredFiles.length > 0) {
payload.ignored_files = options.ignoredFiles;
}
return payload;
}
+45
View File
@@ -210,3 +210,48 @@ def test_single_group_with_multi_book_flag_uses_searched_title(tmp_path):
assert result is not None
assert (ingest / "James S. A. Corey" / "Drive" / "Drive.m4b").exists()
assert statuses[-1] == ("complete", "Complete")
def test_ignored_book_is_not_filed_even_though_the_plan_omits_it(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(tmp_path)
task = _audiobook_task(
book_plan=[
{
"title": "Empire of Silence",
"series_position": 1.0,
"year": None,
"files": ["Book 1 - Empire of Silence/empire.m4b"],
}
],
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
)
result, _statuses = _run(pack, task, ingest, staging)
assert result is not None
filed = sorted(p.name for p in ingest.rglob("*.m4b"))
assert filed == ["Empire of Silence.m4b"]
def test_ignored_file_is_skipped_from_a_single_remaining_book(tmp_path):
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
pack = _nested_pack(tmp_path)
task = _audiobook_task(
multi_book=True,
ignored_files=["Book 2 - Howling Dark/howling.m4b"],
)
result, _statuses = _run(pack, task, ingest, staging)
assert result is not None
assert [p.name for p in ingest.rglob("*.m4b")] != []
assert not any("howling" in p.name.lower() for p in ingest.rglob("*"))
+21
View File
@@ -383,3 +383,24 @@ class TestMatchPlanToFiles:
assert set(groups[0].files) == {b1_t1, b1_t2}
assert groups[1].title == "Book 2"
assert set(groups[1].files) == {b2_t1, b2_t2}
class TestDropIgnoredFiles:
def test_drops_by_relative_path_and_keeps_others(self, tmp_path: Path):
from shelfmark.download.postprocess.packs import drop_ignored_files
files = [tmp_path / "A" / "a.m4b", tmp_path / "A" / "a.epub", tmp_path / "B" / "b.m4b"]
assert drop_ignored_files(files, ["A/a.epub"]) == [files[0], files[2]]
def test_basename_fallback_only_when_unique(self, tmp_path: Path):
from shelfmark.download.postprocess.packs import drop_ignored_files
files = [tmp_path / "A" / "01.mp3", tmp_path / "B" / "01.mp3", tmp_path / "B" / "x.mp3"]
assert drop_ignored_files(files, ["x.mp3"]) == files[:2]
assert drop_ignored_files(files, ["Z/01.mp3"]) == files
def test_no_ignored_is_a_noop(self, tmp_path: Path):
from shelfmark.download.postprocess.packs import drop_ignored_files
files = [tmp_path / "a.m4b"]
assert drop_ignored_files(files, None) is files