feat: ebook libraries, scanning, matching and downloads #17

Merged
jfro merged 22 commits from jtk/ebook-management into main 2026-10-18 17:04:30 +00:00
Owner

Merges Jeremy's jtk/ebook-management branch for v0.6.0. Opened as-is: the branch merges cleanly with current main (after #2–#4), and on that merge mix compile --warnings-as-errors, mix format --check-formatted and mix test pass locally (409 tests, 4 skipped). No commits were added or rewritten.

What it adds

  • Ebook libraries (Admin → Libraries): point at a directory of EPUB, PDF, MOBI/AZW/AZW3 and CBZ files. Scan modes: manual, auto_watch (file_system watcher, debounced) and scheduled (cron).
  • Oban background jobs: ebook_scan queue (1 at a time) walks a library; ebook_process queue (10 at a time) extracts metadata and covers per file. Scan progress shows on the library. Oban Web dashboard at /admin/oban (admin only).
  • Metadata extraction: EPUB/OPF via Saxy, PDF via poppler (pdfinfo/pdftoppm; poppler-utils added to the Docker image), CBZ via a zstream ZIP reader.
  • Book matching: ebooks link to an existing book by ISBN, then fuzzy title/author match (threshold 0.85), or create a new book without duplicating it. Admin → Ebooks lists them.
  • Downloads: the book page lists its ebook files with a download link (GET /ebooks/:id/download, login required).
  • Security fix along the way: the /admin scope now pipes through :require_admin, which closes #8 (import/export reachable by any logged-in user).

Migrations (10)

Oban jobs table (+ upgrade to v14), ebooks, libraries, ebooks.library_id, library scan progress, extended book metadata, and several string → text changes on books and ebooks. They run on app start like the others.

Config / env vars

No new env vars. New app config in config/config.exs (:fuzzy_catalog, :ebooks: supported formats, 100 MB max file size, fuzzy matching on/threshold, watcher and scheduler toggles) and the Oban config. Libraries are set up in the admin UI.
New deps: oban, oban_web, saxy, file_system, zstream, excoveralls (test only).

Known gaps / out of scope

  • OPDS feed is not done.
  • Manual link/unlink of an ebook to a book isn't in this PR.
  • Audit follow-ups specific to this branch, handled in separate PRs after it merges: #14 (ZIP entries read fully into memory) and parts of #15 (pdfinfo/pdftoppm timeouts, Oban Web resolver, symlinks out of a library root).

Closes #8.

Merges Jeremy's `jtk/ebook-management` branch for v0.6.0. Opened as-is: the branch merges cleanly with current `main` (after #2–#4), and on that merge `mix compile --warnings-as-errors`, `mix format --check-formatted` and `mix test` pass locally (409 tests, 4 skipped). No commits were added or rewritten. ## What it adds - **Ebook libraries** (Admin → Libraries): point at a directory of EPUB, PDF, MOBI/AZW/AZW3 and CBZ files. Scan modes: `manual`, `auto_watch` (file_system watcher, debounced) and `scheduled` (cron). - **Oban background jobs**: `ebook_scan` queue (1 at a time) walks a library; `ebook_process` queue (10 at a time) extracts metadata and covers per file. Scan progress shows on the library. Oban Web dashboard at `/admin/oban` (admin only). - **Metadata extraction**: EPUB/OPF via Saxy, PDF via poppler (`pdfinfo`/`pdftoppm`; `poppler-utils` added to the Docker image), CBZ via a zstream ZIP reader. - **Book matching**: ebooks link to an existing book by ISBN, then fuzzy title/author match (threshold 0.85), or create a new book without duplicating it. Admin → Ebooks lists them. - **Downloads**: the book page lists its ebook files with a download link (`GET /ebooks/:id/download`, login required). - **Security fix along the way**: the `/admin` scope now pipes through `:require_admin`, which closes #8 (import/export reachable by any logged-in user). ## Migrations (10) Oban jobs table (+ upgrade to v14), `ebooks`, `libraries`, `ebooks.library_id`, library scan progress, extended book metadata, and several `string` → `text` changes on books and ebooks. They run on app start like the others. ## Config / env vars No new env vars. New app config in `config/config.exs` (`:fuzzy_catalog, :ebooks`: supported formats, 100 MB max file size, fuzzy matching on/threshold, watcher and scheduler toggles) and the Oban config. Libraries are set up in the admin UI. New deps: oban, oban_web, saxy, file_system, zstream, excoveralls (test only). ## Known gaps / out of scope - OPDS feed is not done. - Manual link/unlink of an ebook to a book isn't in this PR. - Audit follow-ups specific to this branch, handled in separate PRs after it merges: #14 (ZIP entries read fully into memory) and parts of #15 (pdfinfo/pdftoppm timeouts, Oban Web resolver, symlinks out of a library root). Closes #8.
jfro added 17 commits 2026-10-18 15:53:32 +00:00
Implement core data model for ebook file management:
- Create Ebook schema with file info, metadata, and processing fields
- Add database migration with indexes and constraints
- Implement Ebooks context with CRUD operations
- Add comprehensive test coverage for schema and context
- Ebooks are shared across all users (no user ownership)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implements MetadataExtractor module to parse ebook files and extract metadata including title, author, ISBN, publisher, language, and description. Also supports extracting cover images from EPUB files.

- Add bupe library for EPUB parsing
- Add pdf_info library for PDF metadata extraction
- Implement MetadataExtractor with extract/1 and extract_cover/1 functions
- Add comprehensive test suite with real EPUB/PDF file generation
- Use ExUnit.CaptureLog to suppress expected errors in tests
- All 179 tests passing

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement Oban worker that scans directories for EPUB/PDF files:
- Discovers files recursively or non-recursively
- Calculates SHA256 file hashes for deduplication
- Creates Ebook records with file metadata
- Enqueues ProcessWorker jobs for metadata extraction
- Validates directory existence and skips existing files

Includes comprehensive test coverage with 8 test cases covering
file discovery, recursive scanning, deduplication, and error handling.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implements the ProcessWorker Oban job that processes ebook files by:
- Verifying file existence and hash integrity
- Extracting metadata (title, author, ISBN, publisher, language, description)
- Extracting and storing cover thumbnails via Storage backend
- Matching ebooks to existing Book records via ISBN or fuzzy title/author matching
- Updating ebook records with extracted metadata and processing status

Includes comprehensive test suite with proper error log capture for clean test output.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add high-level convenience functions to the Ebooks context for:
- Triggering directory scans via trigger_scan/1
- Triggering file reprocessing via trigger_reprocess/2
- Querying ebooks by processing status (list_pending_ebooks/0, list_failed_ebooks/0)
- Deleting ebooks via delete_ebook/1
- Creating changesets via change_ebook/2

All functions include comprehensive test coverage using TDD approach.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds comprehensive configuration for ebook management with the following settings:
- supported_formats: Controls which file formats are scanned (default: epub, pdf)
- max_file_size: Limits file size for processing (default: 100MB)
- enable_fuzzy_matching: Enables automatic book linking via fuzzy matching (default: true)
- fuzzy_threshold: Controls fuzzy matching strictness 0.0-1.0 (default: 0.85)

Workers updated to read from configuration:
- ScanWorker uses supported_formats for file discovery and validates max_file_size
- ProcessWorker uses enable_fuzzy_matching as default when not explicitly provided

Also fixes .coveralls.exs syntax error that prevented mix format from running.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit includes multiple related changes:

feat: add ebook library management with admin UI
- Add Libraries CRUD UI at /admin/libraries for managing library directories
- Support three scan modes: manual, auto_watch (filesystem monitoring), scheduled (cron)
- Include directory tree picker component for intuitive path selection
- Implement LibraryWatcher GenServer for automatic filesystem change detection
- Implement LibraryScheduler GenServer for cron-based scheduled scanning
- Add comprehensive test coverage (341 tests passing)

feat: display all books regardless of collection status
- Switch from INNER JOIN to LEFT JOIN on collection_items
- Make media types informational only, not governing visibility
- Add delete book button with proper confirmation

fix: resolve PDF processing hanging on large files
- Switch from in-memory pdf_info library to command-line pdfinfo
- Resolve 8+ hour hangs on 50MB+ PDF files
- Process large PDFs in sub-second timeframes

fix: handle semicolon-separated tags to prevent overflow
- Add normalize_tags/1 to split and process delimited tag strings
- Prevent varchar(255) overflow errors from long concatenated tags

fix: support namespaced XML elements in EPUB parsing
- Handle container.xml and OPF files with namespace prefixes
- Support various EPUB XML format variations

refactor: migrate EPUB parsing from erlang :zip to zstream
- Use zstream library for more flexible ZIP handling
- Add fallback to erlang :zip for unsupported ZIP formats

refactor: replace xmerl with Saxy for XML parsing
- Rewrite OpfParser to use Saxy instead of xmerl
- Improve parsing reliability and performance

chore: update dependencies and Docker configuration
- Add zstream, file_system, saxy dependencies
- Remove pdf_info dependency
- Add poppler-utils to Dockerfile for pdfinfo command

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Install inotify-tools and poppler-utils in CI environment:
- inotify-tools: Required for file_system library (LibraryWatcher)
- poppler-utils: Provides pdfinfo command for PDF metadata extraction

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Main moved CI to Forgejo, so the GitHub workflow is gone and its
poppler-utils and inotify-tools install moves to .forgejo/workflows/ci.yml.
The Dockerfile keeps main's libsctp1 and libncurses6 and adds poppler-utils.
mix.lock is main's plus the ebook deps at their latest versions; Oban 2.24
wants schema version 14, so a new migration upgrades from 12 instead of
editing the one that may already have run. Coveralls' preferred envs move
into cli/0 next to precommit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ebook and library admin LiveViews checked current_scope.user, which
raises when there's no session, the same bug main fixed in the other admin
pages. Also drop user_friendly_error? clauses the type checker proved
unreachable, put File.stream!'s arguments in the non-deprecated order, and
apply LiveView 1.2's HEEx formatting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MOBI-family files are read straight from their PalmDB records: EXTH holds
the metadata, and the cover offset points at an image record. Only record
0 and the cover are read, and DRM'd Kindle files work since EXTH isn't
encrypted. CBZs get their metadata from ComicInfo.xml and their cover from
its FrontCover page or the first page in natural order. PDF covers are the
first page rendered by pdftoppm, which ships with pdfinfo in poppler-utils.

EPUB covers only worked for EPUB 3's cover-image property; most EPUB 2
files name theirs with <meta name="cover">, which is read now too, with
hrefs percent-decoded and resolved against the OPF's directory. ISBNs come
from urn:isbn: identifiers and lowercase schemes as well, normalized to
ISBN-13. A PDF's Producer is the software that wrote it, so it no longer
becomes the publisher.

The ZIP reading moves out of EpubParser into ZipReader for CBZ to share,
and the OPF is parsed from memory instead of a temp file. That also drops
the Saxy handler modules nested in epub_parser.ex.

Scans and the library watcher match extensions case-insensitively against
supported_formats. Scans also remove records for files that are gone from
disk, once the library folder itself is found, and the watcher counts
removals so deleting a file triggers that. File times are read as UTC,
which they're compared as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reprocessing an ebook ignored the book it was already linked to and
matched again, so a file without an ISBN, or any file with fuzzy matching
off, created a new book every time. A linked ebook now keeps its book.

Matching moves to BookMatcher. An ISBN that matches nothing used to stop
there; now it falls through to title and author. ISBNs match whether the
book has the ISBN-10 or ISBN-13, and duplicates sharing one no longer
raise. Title and author always match after normalizing case, accents,
punctuation, articles, edition postfixes and "Last, First" names; with
fuzzy matching on, the most similar candidate above fuzzy_threshold wins,
which nothing read before. The process queue runs ten jobs at once, so
matching and creating hold an advisory lock, or the EPUB and PDF of one
book would each create it.

New and matched books also get the ISBN-10/13, ASIN and publication date.
Untitled files are named after the file rather than "Unknown Title",
titles over Book's 255 limit are cut instead of failing, covers are stored
with their real image type, and unchanged files keep their stored cover
instead of storing a copy each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /ebooks/:id/download sits with the other book routes behind
require_authenticated_user, so any signed-in user can download. The path
comes from the ebook record, which only the scanner writes. The book page
lists files by name, with the full path on hover, and a download button.

The delete confirmation said deleting a book removes its ebook files. It
doesn't, they're unlinked, and now it says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: ebook libraries in the README, plan status in its docs
All checks were successful
CI / test (pull_request) Successful in 1m25s
43a849ef1a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jfro left a comment

Review by Nye (PR review / audit) — REQUEST CHANGES

What's fine: download route is in the authenticated scope and the path comes only from the DB; admin LiveViews + Oban dashboard are behind require_admin (closes #8); migrations are additive and ordered after main's latest; advisory lock around match/create is right; no conflicts with #18/#19.

Blockers

B1. App can go down when a library is set to auto_watch in Docker. The runtime image has no inotify-tools (only CI has it). file_system then returns :ignore from FileSystem.start_link, and LibraryWatcher.start_watcher only matches {:ok,_}/{:error,_} → CaseClauseError → restart loop → app supervisor gives up. Fix: add inotify-tools to the runtime image; handle :ignore and {:error,_} (log library id + path, keep running); consider not linking. Add a test for :ignore.

B2. Scheduled scan mode never fires. LibraryScheduler starts after Oban and only does Application.put_env on the Oban config; the running Cron plugin never re-reads it. Entries are {expr, {Mod, :fun, args}}, which isn't a valid Oban Cron entry. Fix: one static cron entry for a ScheduledScanWorker that enqueues ScanWorker for each scheduled library whose schedule matches now, skipping ones already scanning.

B3. A library can get stuck in 'scanning' forever, blocking Scan now and Delete. handle_exhausted/handle_cancelled are Oban Pro hooks that OSS Oban never calls; there's no Lifeline plugin; timeouts kill the process before the rescue runs; File.stat! in create_ebook_record raises if a file vanishes mid-scan. Fix: Lifeline plugin, handle the final attempt inside perform, File.stat and skip, make scan_now/scanning? tolerate stale status (or reset at boot). Remove the dead handle_* functions.

Follow-ups (filed as issues, label ebook-branch)

F1 unmounted NAS share wipes ebook records (promoted to release blocker, fixed in this PR) · F2 non-transient processing errors retried · F3 BookMatcher candidate limit can miss the exact match · F4 deleting a library orphans ebooks · F5 Book.changeset casts internal fields (folded into #7) · F6 watcher drops events mid-scan · F7 symlinks in a library expose server files (with #15). #14 (zip bomb) and #15 still apply.

**Review by Nye (PR review / audit) — REQUEST CHANGES** What's fine: download route is in the authenticated scope and the path comes only from the DB; admin LiveViews + Oban dashboard are behind `require_admin` (closes #8); migrations are additive and ordered after main's latest; advisory lock around match/create is right; no conflicts with #18/#19. ### Blockers **B1. App can go down when a library is set to auto_watch in Docker.** The runtime image has no `inotify-tools` (only CI has it). `file_system` then returns `:ignore` from `FileSystem.start_link`, and `LibraryWatcher.start_watcher` only matches `{:ok,_}`/`{:error,_}` → `CaseClauseError` → restart loop → app supervisor gives up. Fix: add inotify-tools to the runtime image; handle `:ignore` and `{:error,_}` (log library id + path, keep running); consider not linking. Add a test for `:ignore`. **B2. Scheduled scan mode never fires.** `LibraryScheduler` starts after Oban and only does `Application.put_env` on the Oban config; the running Cron plugin never re-reads it. Entries are `{expr, {Mod, :fun, args}}`, which isn't a valid Oban Cron entry. Fix: one static cron entry for a `ScheduledScanWorker` that enqueues `ScanWorker` for each scheduled library whose schedule matches now, skipping ones already scanning. **B3. A library can get stuck in 'scanning' forever**, blocking Scan now and Delete. `handle_exhausted`/`handle_cancelled` are Oban Pro hooks that OSS Oban never calls; there's no Lifeline plugin; timeouts kill the process before the rescue runs; `File.stat!` in `create_ebook_record` raises if a file vanishes mid-scan. Fix: Lifeline plugin, handle the final attempt inside `perform`, `File.stat` and skip, make `scan_now`/`scanning?` tolerate stale status (or reset at boot). Remove the dead `handle_*` functions. ### Follow-ups (filed as issues, label ebook-branch) F1 unmounted NAS share wipes ebook records (promoted to release blocker, fixed in this PR) · F2 non-transient processing errors retried · F3 BookMatcher candidate limit can miss the exact match · F4 deleting a library orphans ebooks · F5 Book.changeset casts internal fields (folded into #7) · F6 watcher drops events mid-scan · F7 symlinks in a library expose server files (with #15). #14 (zip bomb) and #15 still apply.
Oban OSS never calls handle_exhausted/handle_cancelled, so those are gone.
A telemetry handler marks the library or ebook failed when a job is
discarded (timeouts included) or cancelled, the Lifeline plugin rescues
jobs orphaned by a restart, and scanning? checks the jobs table so a
stale status no longer blocks Scan now or Delete. Scans start through
Libraries.start_scan, which marks and enqueues in one transaction.
The Cron plugin reads its crontab once at start, so editing the app env
afterwards did nothing, and the entries weren't valid. One static cron
entry runs ScheduledScanWorker every minute, which starts scans for the
scheduled libraries that are due (UTC). Schedules are validated with
Oban's cron parser.
Adds inotify-tools to the runtime image. The watcher handles :ignore and
errors from FileSystem.start_link, records the reason on the library and
traps exits so a dying watcher only stops watching its own library.
fix: an unmounted library share no longer wipes its ebook records
All checks were successful
CI / test (pull_request) Successful in 43s
c7e0ec4273
A scan that finds no files while the library has records aborts and keeps
them; removal re-checks the directory first. A file vanishing mid-scan is
skipped instead of failing the scan, and ebooks left pending by a dead job
are queued again.
Author
Owner

Pushed fixes for the review blockers (4 new commits, no history rewrite), plus the NAS-unmount wipe, which is now a release blocker:

  • B1 auto-watch crash (ef40c16): inotify-tools added to the runtime image. LibraryWatcher handles :ignore / {:error, _} / anything else from FileSystem.start_link, logs library id + path, records "Auto-watch unavailable: …" on the library, and traps exits so a dying watcher only drops its own library. Tests: :ignore, {:error, _}, killed watcher.
  • B2 scheduled scans (8f740fb): one static crontab entry {"* * * * *", ScheduledScanWorker} (own ebook_schedule queue). It calls LibraryScheduler.run_due/1, which matches each scheduled library's cron (via Oban.Cron.Expression, UTC, judged at the job's scheduled_at) and starts the due ones, skipping ones already scanning or with a missing directory. The GenServer and the runtime put_env are gone. Library.changeset now rejects invalid cron expressions. The UI says times are UTC.
  • B3 stuck in scanning (f7155c4): dead handle_exhausted/handle_cancelled removed. Oban.Plugins.Lifeline (rescue_after 30 min) added. A telemetry handler (Ebooks.JobFailureHandler) marks the library/ebook failed when a ScanWorker/ProcessWorker is discarded (timeouts on the last attempt included) or cancelled. Libraries.scanning?/1 treats "scanning" as stale when there's no available/scheduled/executing/retryable ScanWorker for the library or ProcessWorker for its ebooks (2-minute grace after the last update), so Scan now and Delete work again. Libraries.start_scan/1 marks + enqueues in one transaction and is used by Scan now, the watcher and the scheduler. File.stat! → File.stat with skip. Ebooks left pending/processing by a dead job are re-queued on the next scan.
  • F1 NAS unmount (c7e0ec4): a scan that finds 0 files while the library has records aborts with "Is the share mounted? Kept the existing records." and marks the library failed; removal also re-checks that the directory still has entries. A missing directory already aborted before removal. Tests for empty mountpoint, missing dir, empty new library.

mix precommit: 419 tests, 0 failures.

Pushed fixes for the review blockers (4 new commits, no history rewrite), plus the NAS-unmount wipe, which is now a release blocker: - **B1 auto-watch crash** (ef40c16): `inotify-tools` added to the runtime image. `LibraryWatcher` handles `:ignore` / `{:error, _}` / anything else from `FileSystem.start_link`, logs library id + path, records "Auto-watch unavailable: …" on the library, and traps exits so a dying watcher only drops its own library. Tests: `:ignore`, `{:error, _}`, killed watcher. - **B2 scheduled scans** (8f740fb): one static crontab entry `{"* * * * *", ScheduledScanWorker}` (own `ebook_schedule` queue). It calls `LibraryScheduler.run_due/1`, which matches each scheduled library's cron (via `Oban.Cron.Expression`, UTC, judged at the job's `scheduled_at`) and starts the due ones, skipping ones already scanning or with a missing directory. The GenServer and the runtime `put_env` are gone. `Library.changeset` now rejects invalid cron expressions. The UI says times are UTC. - **B3 stuck in scanning** (f7155c4): dead `handle_exhausted`/`handle_cancelled` removed. `Oban.Plugins.Lifeline` (rescue_after 30 min) added. A telemetry handler (`Ebooks.JobFailureHandler`) marks the library/ebook failed when a ScanWorker/ProcessWorker is discarded (timeouts on the last attempt included) or cancelled. `Libraries.scanning?/1` treats "scanning" as stale when there's no available/scheduled/executing/retryable ScanWorker for the library or ProcessWorker for its ebooks (2-minute grace after the last update), so Scan now and Delete work again. `Libraries.start_scan/1` marks + enqueues in one transaction and is used by Scan now, the watcher and the scheduler. `File.stat!` → `File.stat` with skip. Ebooks left pending/processing by a dead job are re-queued on the next scan. - **F1 NAS unmount** (c7e0ec4): a scan that finds 0 files while the library has records aborts with "Is the share mounted? Kept the existing records." and marks the library failed; removal also re-checks that the directory still has entries. A missing directory already aborted before removal. Tests for empty mountpoint, missing dir, empty new library. `mix precommit`: 419 tests, 0 failures.
jfro left a comment

Review by Nye (PR review / audit) — APPROVE @c7e0ec4

CI green, merges cleanly onto main (5dd20bb). Verified against Oban 2.24.2 source: telemetry :state values include :discard/:cancelled so JobFailureHandler fires correctly; Oban.Plugins.Lifeline/Cron map to Oban.Lifeline/Oban.Cron internally so config is valid; Lifeline rescue_after 30m > ScanWorker 10m timeout. B1 (inotify-tools in runtime image + :ignore/{:error}/EXIT handling), B2 (per-minute ScheduledScanWorker + valid_cron? validation, UTC noted in UI), B3 (Lifeline + JobFailureHandler + job-table stale detection in scanning?/start_scan) and F1 (abort when 0 ebook files found but records exist, plus re-check before removals) are all resolved.

Non-blocking follow-ups (to be filed as one low issue after merge):

  • (a) a stale library still shows the Scanning badge until someone clicks Scan now; reset scanning_status when scanning?/1 detects it stale.
  • (b) use the new service keys (lifeline: [rescue_after: ...], cron: [crontab: ...]) instead of the deprecated Oban.Plugins.* names.
  • (c) LibraryScheduler uses Oban.Cron.Expression, which is @moduledoc false (private API); recheck on Oban upgrades.
  • (d) start_scan is check-then-insert, so watcher + scheduler in the same minute can queue two scans (serialized by ebook_scan concurrency 1, harmless); unique: [keys: [:library_id], states: :incomplete] on ScanWorker would close it.
**Review by Nye (PR review / audit) — APPROVE** @c7e0ec4 CI green, merges cleanly onto main (5dd20bb). Verified against Oban 2.24.2 source: telemetry `:state` values include `:discard`/`:cancelled` so JobFailureHandler fires correctly; `Oban.Plugins.Lifeline`/`Cron` map to `Oban.Lifeline`/`Oban.Cron` internally so config is valid; Lifeline `rescue_after` 30m > ScanWorker 10m timeout. B1 (inotify-tools in runtime image + `:ignore`/`{:error}`/EXIT handling), B2 (per-minute ScheduledScanWorker + `valid_cron?` validation, UTC noted in UI), B3 (Lifeline + JobFailureHandler + job-table stale detection in `scanning?`/`start_scan`) and F1 (abort when 0 ebook files found but records exist, plus re-check before removals) are all resolved. Non-blocking follow-ups (to be filed as one low issue after merge): - (a) a stale library still shows the Scanning badge until someone clicks Scan now; reset `scanning_status` when `scanning?/1` detects it stale. - (b) use the new service keys (`lifeline: [rescue_after: ...]`, `cron: [crontab: ...]`) instead of the deprecated `Oban.Plugins.*` names. - (c) LibraryScheduler uses `Oban.Cron.Expression`, which is `@moduledoc false` (private API); recheck on Oban upgrades. - (d) `start_scan` is check-then-insert, so watcher + scheduler in the same minute can queue two scans (serialized by ebook_scan concurrency 1, harmless); `unique: [keys: [:library_id], states: :incomplete]` on ScanWorker would close it.
Merge remote-tracking branch 'origin/main' into jtk/ebook-management
All checks were successful
CI / test (pull_request) Successful in 44s
7e3add1c74
jfro merged commit e58e20ff35 into main 2026-10-18 17:04:30 +00:00
jfro deleted branch jtk/ebook-management 2026-10-18 17:04:30 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfro/fuzzy_catalog!17
No description provided.