From 51f32b374d035bb856ddafa43422b6864987d830 Mon Sep 17 00:00:00 2001
From: amura11 <1559993+amura11@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:03:05 -0600
Subject: [PATCH] feat(packs): let users choose which files, books and formats
to download (#1480)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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
## 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
---
shelfmark/core/models.py | 2 +
shelfmark/download/orchestrator.py | 14 ++
shelfmark/download/outputs/folder.py | 7 +-
shelfmark/download/postprocess/packs.py | 28 +++
shelfmark/download/postprocess/transfer.py | 15 +-
.../src/components/FormatReviewPanel.tsx | 113 +++++++++++
.../src/components/PackReviewPanel.tsx | 187 ++++++++++++------
src/frontend/src/components/ReleaseModal.tsx | 86 +++++++-
src/frontend/src/services/api.ts | 1 +
src/frontend/src/tests/formatReview.test.ts | 56 ++++++
src/frontend/src/tests/packReview.test.ts | 23 +++
src/frontend/src/utils/formatReview.ts | 57 ++++++
src/frontend/src/utils/packReview.ts | 22 +++
src/frontend/src/utils/releasePayload.ts | 5 +
tests/core/test_processing_packs.py | 45 +++++
tests/download/test_packs.py | 21 ++
16 files changed, 611 insertions(+), 71 deletions(-)
create mode 100644 src/frontend/src/components/FormatReviewPanel.tsx
create mode 100644 src/frontend/src/tests/formatReview.test.ts
create mode 100644 src/frontend/src/utils/formatReview.ts
diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py
index 5aafc2eb..90a427a3 100644
--- a/shelfmark/core/models.py
+++ b/shelfmark/core/models.py
@@ -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
diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py
index e37e99ae..13371a30 100644
--- a/shelfmark/download/orchestrator.py
+++ b/shelfmark/download/orchestrator.py
@@ -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")),
diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py
index 3f549d35..8cc91105 100644
--- a/shelfmark/download/outputs/folder.py
+++ b/shelfmark/download/outputs/folder.py
@@ -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:
diff --git a/shelfmark/download/postprocess/packs.py b/shelfmark/download/postprocess/packs.py
index 5cd97726..0ca2b0b8 100644
--- a/shelfmark/download/postprocess/packs.py
+++ b/shelfmark/download/postprocess/packs.py
@@ -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],
diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py
index 0be089c9..6a48110e 100644
--- a/shelfmark/download/postprocess/transfer.py
+++ b/shelfmark/download/postprocess/transfer.py
@@ -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,
diff --git a/src/frontend/src/components/FormatReviewPanel.tsx b/src/frontend/src/components/FormatReviewPanel.tsx
new file mode 100644
index 00000000..922f0814
--- /dev/null
+++ b/src/frontend/src/components/FormatReviewPanel.tsx
@@ -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;
+ isSubmitting: boolean;
+}
+
+export const FormatReviewPanel = ({
+ release,
+ groups,
+ onBack,
+ onConfirm,
+ isSubmitting,
+}: FormatReviewPanelProps) => {
+ const [selected, setSelected] = useState>(
+ new Set(groups.map((group) => group.format)),
+ );
+ const [expanded, setExpanded] = useState(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 (
+
+
+
Choose formats to download
+
+ {release.title} comes in several
+ formats. Switch off any you don’t want; they won’t be filed.
+
Each book below is filed separately with its own title. Fix any titles before downloading
@@ -85,68 +100,110 @@ export const PackReviewPanel = ({
YearFiles
- {books.map((book, index) => (
-