Self-hosted collection manager that tracks a watchlist of authors from OpenLibrary and reconciles their published works against your local ebook files so you can see, per author, which books you own and which you're missing. Also handles ingesting new files from a drop folder and re-running matching against previously-unmatched files.
You don't need an existing ebook library to start. Point it at an empty
folder and grow the collection through the drop-folder pipeline, use any plain
folder tree (the <Root>/<Author>/<Title> library layout is supported but not
required), or run it with no local files at all as a pure author/works tracker
and wishlist.
- Server — ASP.NET Core 10 + EF Core 10 (SQL Server) + automatic migrations
- Client — React 19 + Vite + react-router
- Scheduling — Hangfire (SQL Server storage) with a single-worker policy so scheduled jobs mutually exclude
- NZB search — configurable URL-template sites let you jump straight to an NZB search for any unowned book
- Author management — priority ratings (0–5), blacklist, per-author next-fetch scheduling with optional fixed interval override, exclusion reasons
- Data source of truth — OpenLibrary (works only, English, published 1930 or later)
- Local source — a structured
<Root>/<Author>/<Title>folder tree or flat-file layout under one or more library roots - Ingest formats — EPUB, MOBI / AZW / AZW3 / AZW4 / KF8 / PRC / PDB,
FB2 / FBZ /
.fb2.zip, PDF, LIT (magic validated; title/author via filename fallback), CBZ (ComicInfo.xml) / CBR, DOCX / DOC / ODT / RTF (Dublin Core where available, else filename), OPF, TXT (filename fallback only). Files whose extension isn't on this list are ignored by the scanner — so a folder of, say,.cbror.docfiles won't appear on the Untracked/Unknown views unless their type is recognised here - In-browser preview — EPUB / PDF / TXT render natively via epub.js,
the browser's PDF viewer, and a plain
<pre>block respectively; every other supported format (MOBI / AZW / AZW3 / FB2 / LIT / DOCX / ODT) is automatically converted to EPUB on the server first, so it can be read in-browser too (available on the author page and in the Untracked browse pane for files not yet linked to a book) - Pushover alerts — optional per-author push notifications when a new book by that author is detected during a refresh
| Page | Route | Purpose |
|---|---|---|
| Find a Book | /find |
Find any book by part of its title — a live (debounced) case-insensitive substring search over every catalogue title, prefix matches first. Each result links to the book on its author's page (and shows author, series, year, owned). Shareable via ?q=. Backed by GET /api/books/title-search |
| Search | /search |
Full-text search across the text of your matched ebooks (opt-in). Shows index progress with a "run indexing now" button (a background batch) and a clear control, then returns books with a matching snippet. Off until enabled in Settings |
| Home | / |
Landing dashboard: cover art plus live stat cards (wanted, damaged copies, untracked folders, unknown files, authors due for refresh, releases this year, files added this week, owned/missing/active counts) that link straight into the page that acts on each. Backed by the cheap count-only /api/dashboard endpoint. This is the default route (replaced the old redirect to the author list) |
| Authors | /authors |
Full watchlist with filter, sort, pagination, A–Z jump index, per-row selection, bulk status (Active / Pending / Excluded), author merge, and a per-row Refresh OL data button that re-fetches that author's works from OpenLibrary. + Add author searches OpenLibrary; if they're not listed there yet, Add "…" manually creates a hand-added author (synthetic XX…A key) that behaves like any other — shows in the list, can own books and be starred, is exempt from the no-English-works / publication-date exclusion rules, and is linked to its real OpenLibrary record automatically by the promote-manual-books job once OL lists them |
| Author detail | /authors/:id |
Books (grouped by series), a per-book →epub button that converts a non-EPUB copy via Calibre (POST /api/books/{id}/convert-to-epub), bio, read status, NZB links, reMarkable send, library scan timestamp, bulk "Mark all missing as wanted". A book's local files show its live copies inline; any copies under the archive folder are hidden behind a per-book "Show N archived" toggle. The ownership column carries two checkboxes — Physical (a print copy you hold) and Other edition ("got but in a different edition than catalogued"); either marks the book owned (it leaves the Missing/Wanted lists) and the status reads e.g. "Yes (other edition)". Book titles here are the one place that links out to the OpenLibrary work page; on every other page a book title instead deep-links to that book on its author page (/authors/:id#book-:bookId), scrolling to and briefly highlighting the row. Shared/co-authored series: when a series on this page also has volumes by other authors, those volumes are pulled in (tinted, with a "by …" byline linking to that author) so the full series shows on every participating author's page. Their local files show inline (preview / send / unmatch) exactly like the author's own books; they're excluded only from this author's own counts, bulk actions and file-matching pickers. A "co-author" that is really a same-name duplicate record (or carries a work this author already has) is skipped, so a duplicated author doesn't list every shared volume twice. The Similar author names suggestions show each candidate's star count |
| Up Next | /up-next |
Reading queue: the next owned-but-unread volume for every series you've started (read ≥1, own the next), ordered by reading position. Each row links to the author and, when reMarkable is connected and the book has a file, a one-click → reMarkable send. Backed by GET /api/books/up-next |
| Recent Releases | /recent-releases |
New works from starred authors (last 5 years). Series books show the series name and number. Suppressed and foreign books are excluded — flagging any edition of a title hides the whole title, so a duplicate edition can't leak it back. De-duplication resolves a book's author to its canonical (following any author link), so a title catalogued under two linked records of the same person (e.g. two "Terry Brooks") collapses to one row |
| All Releases | /all-releases |
New works from all tracked authors. The year filter defaults to the current year only (clear either year box to widen the range). Series books show the series name and number. Suppressed and foreign books are excluded — flagging any edition of a title hides the whole title, so a duplicate edition can't leak it back |
| Missing Works | /missing |
Unowned books from starred authors — bulk-own, wanted flag, genre filter, year range filter, CSV export, per-book file-candidate matching panel (fuzzy-matched from author unmatched files + unknown folder) |
| Wanted | /wanted |
Wanted books grouped by author — per-book selection, author-level select-all, bulk remove from wanted, NZB search links, author priority stars, and (when download automation is configured) a Grab button that searches the indexer and sends the best NZB to SABnzbd |
| Physical Only | /physical-only |
Books marked physically owned (print copy) that have no ebook file here and are not flagged "got in a different edition" — i.e. works you hold on paper but might still want digitally. Grouped by author. Backed by GET /api/books/physical-only |
| Activity | /activity |
Read-only audit trail of consequential actions — files archived / deleted / auto-archived, manual books added (manual-add), and manual books promoted to their real OpenLibrary work (promote-manual, by the author-refresh or promote-manual-books job), plus manual authors added (manual-author-add) and promoted (promote-manual-author) — what changed, when, and what triggered it (user vs a job). Each row carries the book/author title and details. Backed by GET /api/activity and the ActivityLog table |
| Health | /health |
Operational view: backlogs (unmatched files, untracked scans, __unknown files, and untracked-not-yet-LLM-parsed — the llm-identify job's remaining candidates), the LLM job's daily calls-left and (when provider admin keys are set in Settings) the real $ spend over the last 30 days per provider — spend, not remaining balance, since the providers don't expose a credit balance via the API; authors by status and by creation source (provenance), authors created over the last 14 days, the count prune-authors would remove, and current job state |
| Starred Authors | /starred |
Authors with priority ≥ 1, with per-author publication count, how many are obtained (ebook file linked, or flagged owned another way — physical/different-edition), how many are not obtained at all, and unmatched local files. Backed by GET /api/authors/starred |
| Recommendations | /recommendations |
"Authors you might want to watch" — un-starred authors already in your catalogue ranked by how well their genres overlap the books you own, plus co-authors on series you own; one-click ★ Watch promotes one onto the watchlist, or ✕ Not interested dismisses one for good so it's never suggested again — a dismissed author shows an Undo banner on its author-detail page so the rejection is reversible. Backed by GET /api/recommendations (local data only, no OpenLibrary calls); reject/un-reject via POST/DELETE /api/recommendations/{id}/reject |
| Series | /series |
Hierarchical series tree with owned/total progress bars; create new series; inline edit of name, primary author, additional authors, parent series, and reading order position; deep-linkable via ?q=SeriesName |
| Series Completion | /series-completion |
Series ranked by how close to complete you are (most-complete-but-unfinished first), each with an owned/total bar and a one-click "Want N missing" button that marks every not-owned volume in the series as Wanted. Optional "hide complete series" filter |
| Collections | /collections |
User-defined shelves (e.g. "To read 2026", "Favorites") that cut across authors/series — create / rename / delete, view a shelf's books. Books are added from the shelf button on any book row (author detail). Also shows auto genre tags derived from OpenLibrary subjects; clicking one opens /genre/:genre, a browse-by-genre book list (owned / missing filter) |
| Stats | /stats |
KPI cards, books-read-by-year chart, top genres, per-author coverage, file-format breakdown chart, files-acquired-by-month chart, plus reading insights (books read in the last 12 months, pace/month, owned-but-unread backlog, and years-to-clear-backlog at current pace) |
| Duplicates | /duplicates |
Books matched to more than one real local copy (a file, or a folder that actually holds an ebook — empty/stale title-folder rows are not counted). Each copy shows its integrity status (✓ ok / ✗ damaged / ? unchecked) and the keeper is never a damaged copy when a healthy one exists. Each copy also has an Unlink action (no confirm dialog) to detach a wrongly-matched file from the book (undo a false match) — the file stays on disk under its author, is marked ManuallyUnmatched so the classic sync matcher won't re-link it, and the exact (file, book) pair is permanently blocked (BlockedBookLink / LinkBlocklist) so no automated matching path or apply flow can ever re-add that specific wrong match either (POST /api/books/files/{id}/unlink) — see Blocked book links. A per-group Unlink all button (confirm dialog) covers the case where the whole group — keeper included, not just the extras — is a wrong match: one click detaches and blocks every copy in the group, reusing the same bulk POST /api/books/duplicates/actions endpoint as Archive extras / Delete extras (action: "unlink") |
| Damaged | /damaged |
Ebook files the integrity job couldn't open/convert, or that have fewer than 20 pages — grouped by book, with NZB replacement-search links, per-book "add to Wanted" + "archive all bad copies", per-file preview/mark-OK/recheck/remove/restore-from-archive, an on-demand Check now, a ★ Starred authors only filter (starred authors are flagged with a ★ on each group), and a backlog gauge ("⏳ N still to check") showing how many files still await an integrity check (raise Max files per run on Settings to clear it faster). See Book integrity check |
| Identified | /identified |
Author/title/series guessed from the front matter of unmatched & untracked files (the Identify books from content job), to review, preview, Apply (match to an OpenLibrary work), or dismiss. A per-row Find on OL opens the OpenLibrary title search and the selected work is matched immediately — author resolved/created, book ensured, file moved and linked, row retired — no separate Apply click. Tracked files (already in an author folder) show no "accept author" action — their author is fixed by the folder. Bulk "do all" actions at the top — Apply all ISBNs, Assign all untracked, Build all series, and Dismiss all (clears the whole list, respecting the author filter) — let you action the list without going row by row. Untracked rows also offer Map to Unknown Author (files the book under a catch-all "Unknown Author" so it leaves limbo — you can still match it to an OpenLibrary book afterwards and it stays under Unknown Author) and a per-row 🗑 Delete. A top-level + Add author button creates an author (OpenLibrary or manual) for files whose author isn't in the library yet, then jumps to their page to match their files. See Identifying books from content |
| Manual Books | /manual-books |
Every manually-added book (works not on OpenLibrary), grouped by author with a filter per column (title, author, year, series, owned), inline edit and delete. Filtering and paging are server-side (100 per page) so the list stays fast even with 100k+ manual books — the filter boxes drive the query and any change jumps to page 1. Rows with no ebook file carry the same external book-search links (Z-Library / NZB indexers) an OpenLibrary book gets elsewhere — gated on the file, not the owned flag, so a manual book auto-marked "owned (other edition)" by the duplicate-editions job still offers the search. Since a manual book isn't on OL, those searches are the main way to go find it. The daily promote-manual-books job links each one to its real OpenLibrary work once OL lists it |
| Untracked | /untracked |
Unclaimed library folders and __unknown bucket — folders AND loose book files sitting directly at the quarantine root (with one-click "Try matching all" against the current watchlist). Both lists default to newest-first (by folder/file modified time) so a file just moved into __unknown by the incoming job surfaces at the top instead of being buried alphabetically; the Order dropdown also offers oldest / name / item-count sorts. Each unclaimed folder shows an integrity tally (✓ ok / ⚠ damaged / ◌ unchecked) across its files, and an Integrity filter narrows the list to OK / Damaged / Unchecked (which then hides the __unknown items, since loose quarantine files have no integrity record). The browse pane drills into a folder, previews EPUB/PDF/TXT files in-place, matches a single file to an OpenLibrary work, and deletes files/folders (disk + DB) — and stays open after matching when other files remain; loose files get Preview / Match to book / Return to Incoming / Delete. Each unclaimed folder also has Map to Unknown Author — files everything in it under the catch-all "Unknown Author" (created on demand; kept out of OpenLibrary refresh) so it leaves the unclaimed list, while still allowing the books to be matched on OpenLibrary later |
| Unmatched physical | /physical-unmatched |
Editable list of physical-books-import rows that couldn't be matched; "Re-run matching" re-tries the whole list against the current library |
| Sync | /sync |
Live sync dashboard with phase tracking and progress, plus a Background jobs panel with a run-now button and live status for every job, grouped by category |
| Schedules | /schedules |
Cron expressions and enabled/disabled flags for every background job, grouped by category; a collapsible "What this job does" explainer and a per-job last-N-run history panel. Both the Schedules and Sync pages render the same jobs from one shared catalog (src/jobCatalog.js) so they can't drift apart |
| Settings | /settings |
Library locations, incoming folder, custom quarantine (__unknown) folder override, Pushover credentials (+ "Send test"), ignored folders, title blacklist, author blacklist, NZB sites, reMarkable pairing, book integrity check (max files per run + replacement formats), Background job run limits (per-run caps for promote-manual-books, resolve-works, resolve-isbns, retry-isbn-misses, assign-authors, auto-replace-damaged, prune-authors — each defaults to the job's built-in cap), Goodreads + physical-books import |
Authors are a curated watchlist, driven by OpenLibrary. You search for an author from the UI and add them — the sync then does the rest.
- Fetch English works for every tracked author via
/search.json?author_key=...&language=eng. OpenLibrary returns one row per work, so variants/editions are collapsed automatically. Each work'ssubjecttags (genre) andseriesname are stored alongside the title so they're available without extra API calls. Starred authors (priority ≥ 1) bypass the English-only filter so works in any language are retrieved. - Backfill genres — on each subsequent sync, any existing book whose
Subjectscolumn isNULL(never checked) is updated from the OL response. An empty string""is written when OL has no subjects for that work, acting as a sentinel so the book is not re-checked on future syncs. - Exclude authors that have no English works, or whose works were all
first published before 1930. Starred authors are always kept Active regardless
of date or language criteria. An author whose ebook files are already on
disk is also never excluded — what OpenLibrary returns says nothing about
books you already hold, and excluding such an author would make the sync
sweep their whole folder into
__unknown. The same guard applies to the manual bulk Excluded action (authors with files are skipped) and, in reverse, the folder reconciliation: when a folder on disk maps to an excluded or blacklisted author it is un-excluded / un-blacklisted and kept, not quarantined. - Fetch author bio — on the first refresh after an OL key is resolved, the
author's bio is pulled from
/authors/{key}.jsonand stored. Displayed on the author detail page. - Scan every enabled library location for library-structured folders:
<Root>/<Author>/<Title (id)>/…. - Match each library author folder to a tracked author by normalized name
(handles
Last, First, diacritics, casing). Match each title folder to a work by normalized title — see Title matching for the multi-candidate strategy that handlesby Author, trailing parens, etc. - Surface library folders with no matching tracked author as "unclaimed" — click one to kick off an OpenLibrary search pre-filled with that folder's name, so you can add the author in one click.
- Stamp
CalibreScannedAton each author as their file-matching pass completes. On the next run, authors are processed in ascending order of this timestamp (nulls first) so interrupted runs catch up stragglers before re-scanning recently-processed authors. - Prune
LocalBookFilerows not seen during the scan (covers deleted files and disabled locations). - A book is owned if any local file matched it or you manually marked it.
The author detail page also lists unmatched local files (files in the author's folder that didn't line up with any tracked work). You can force-match one to a work, unmatch an existing link, return the file's folder to the incoming bucket for reprocessing, or run an OpenLibrary title search using the local filename (not the full path). Accepting an OpenLibrary result there is treated as a revised link/match of the physical file: the app creates or reuses the selected work locally, links the file to it, and if the selected work belongs under a different author, moves the file/folder into that author's folder before saving the new link. The force-match dropdown still accepts books owned by any non-pen-name linked child author too, so a canonical's view can claim its duplicates' works without re-parenting them in the DB first.
Near the top of the page, a "Similar author names" panel lists other, not-yet-linked authors whose name resembles this one — spelling/initials variants like "Iain Banks" / "Iain M. Banks" and exact homonyms — ranked by a Jaro-Winkler score (a SQL prefilter on a shared first/last name token keeps this off the full author table). Tick any that are really this author and link them under this author as the canonical/primary in one action; each link relocates the child's files into this author's folder (the standard duplicate-link merge). Authors already linked to someone, authors that are themselves a canonical with children, and this author itself are never suggested. The panel only appears on a top-level author page (a child/pen-name page isn't a primary).
Below that, a "Same-name authors' unmatched files" section lists the unmatched files of every other, distinct author who shares this author's name (homonyms OpenLibrary has split across separate records / keys — these are NOT the linked children folded into this view, whose files already appear above). Each same-name author is its own group, collapsed by default and expandable. Within a group you can match a file onto one of this author's works: the file is physically moved into this author's folder and linked to the chosen book (the author↔file link is folder-driven, so a DB-only reassignment would revert on the next sync). This is the quick way to correct copies filed under the wrong one of several same-named author records. Only real ebook rows appear (the same disk-reality filter as the main unmatched list), and the adopt action is guarded so it only ever accepts a file whose current author genuinely shares this name.
See OpenLibrary integration for what the app calls OpenLibrary for, how it identifies itself, and how calls are rate-limited.
OpenLibrary is the catalogue of record — every tracked author and all of their works originate from it. The app talks to these OpenLibrary endpoints:
| Used for | Endpoint | When it fires |
|---|---|---|
| Author search | search/authors.json?q= |
Adding an author from the UI, the Untracked page's "Suggest from OL" button, and resolving an unresolved watchlist name |
| Work/title search | search.json?title=… |
Resolving unmatched physical-book imports and unmatched local files from the author detail page; these searches intentionally run by title/filename only so a wrong local author guess doesn't hide the right work |
| Works fetch | search.json?author_key=…&language=eng |
Every per-author refresh — one row per work, carrying subject (genre) and series |
| Author detail / bio | authors/{key}.json |
The first refresh after an author's OL key resolves, to store their bio |
| Author-merge changelog | OpenLibrary's daily merge-authors change log | The author-updates job — rewrites local OL keys when OpenLibrary folds two author records into one |
| Author bulk dump | ol_dump_authors_latest.txt.gz |
The seed job — fills the local OpenLibraryAuthor catalogue so author lookups are instant and offline |
| Cover images | covers.openlibrary.org CDN |
Loaded directly by the browser and by OPDS readers — never proxied through the server |
Two things deliberately make no API calls: the same-name-authors job and
the offline portion of author lookups both read the locally-seeded
OpenLibraryAuthor catalogue instead of hitting the network.
OpenLibrary asks any application making frequent API use to send a User-Agent
header that names the application and gives a contact address, so they can
reach the operator about request volume. Identified callers also get a
3× higher rate limit.
The header is built from two values — an application name and a contact
email — producing User-Agent: <AppName> (<ContactEmail>).
⚠️ If you run this app yourself you MUST set your own app name and contact email — never reuse anyone else's. The header is a contact channel: OpenLibrary uses it to reach the operator of that deployment about its traffic. Sending another person's name and email means they get contacted about your requests. There is deliberately no shared default identity, and nothing identifying is shipped in the repo.
Set both on the Settings page under OpenLibrary identity. They're
stored in the database (the OpenLibraryAppName / OpenLibraryContactEmail
AppSetting rows) — never in appsettings.json or any other file in the
repo — and an edit takes effect on the very next API call, with no restart.
Until you set them, the app sends a generic User-Agent: TheLibrary and runs
at the anonymous 1 req/sec tier. The app name may be a URL — it's sent with
TryAddWithoutValidation, so a value that isn't a strict User-Agent product
token still goes out verbatim. With a contact email set the deployment is
identified and gets the 3 req/sec tier; with it blank it stays anonymous at
1 req/sec.
A single shared OpenLibraryRateLimiter serializes every outbound
OpenLibrary call — author searches, works fetches, bio lookups and the
author-update changelog all funnel through the one instance — and enforces a
minimum gap between consecutive calls. The gap is chosen once at startup from
whether a contact email is configured:
| Tier | Condition | OpenLibrary ceiling | Enforced gap | Effective rate |
|---|---|---|---|---|
| Identified | ContactEmail is set |
3 requests/sec | 350 ms | ~2.85 req/sec |
| Anonymous | ContactEmail is blank |
1 request/sec | 1100 ms | ~0.9 req/sec |
The enforced gap sits just under the applicable ceiling so timing jitter can't
tip a burst over the limit, and because everything shares the one limiter a
long author-refresh sweep and an interactive search can't collectively exceed
the rate. The client also retries 429 and 5xx responses, honoring any
Retry-After header.
Self-demotion on 429. If OpenLibrary ever returns an HTTP 429 (rate-limited) despite the pacing, the limiter immediately demotes itself to the 1100 ms anonymous gap for the rest of the process — it returns to the configured (identified) pace only on the next app restart. Clearing the contact email has the same effect from the start, so either way you can't keep hammering OpenLibrary above the rate it's willing to serve.
Genre tags are sourced from OpenLibrary's subject field (LCSH-style tags such
as "Science fiction", "Mystery fiction", "Historical fiction") and stored
as a semicolon-delimited string on each Book row. They populate automatically
during sync — no extra API calls are needed.
Genre chips are displayed under each book title on the author detail page (up to 4 tags). On the Missing Works page, a genre filter dropdown narrows the list to a single tag. The Stats page shows the top 20 genres across all owned books.
dc:subject from EPUB files is also extracted during incoming processing and
stored the same way.
Each book carries a ReadStatus (Unread / Reading / Read / DNF) and an optional
ReadAt date. Status is editable via a dropdown on the author detail page.
The Stats page shows a year-by-year bar chart of books marked Read.
To bulk-import reading history, use Goodreads import on the Settings page (see Goodreads import).
Optional. Configure a Pushover application token + user key on the Settings page (both are required; either being blank disables the feature) and then flip the "Pushover alert when a new book is detected" checkbox on each author's detail page to opt them in. The "Send test" button on Settings posts a one-off notification so you can verify credentials before relying on them at 3 AM.
Alerts are fired by AuthorRefresher whenever a new Book row is inserted
during an OpenLibrary refresh. Two guardrails keep the channel quiet:
- No backfill spam — the very first refresh of a newly-added author
inserts every book they've ever written; the alert is suppressed on that
run by checking
LastSyncedAt is nullat the start. - Publish-year gate — only fires when
FirstPublishYear >= currentYear - 1.FirstPublishYearis the only publish-date signal OpenLibrary exposes via the works endpoint (no month/day), so the gate is conservative on purpose. Older works are silently filtered.
Notifications include the author name, book title, year, and a deep link to the OpenLibrary work page. Failures are logged but never surface in the refresh outcome — a Pushover outage shouldn't fail a sync.
Any book on an author's detail page can be suppressed via the per-row
"suppress" action. Suppressed books are moved into a collapsed <details>
section at the very bottom of the author page (below the unmatched-files
panel), out of the way of the main grouped-by-series view. The bucket lists
title, year, optional series, and an Unsuppress button to restore the
book to the main list.
The persistent Book.Suppressed flag is the user's way of saying "OpenLibrary
keeps surfacing this work, but I don't want it on my list" — useful for
non-English variants, obvious duplicates, or unwanted clutter. It does not
delete the book or affect refresh logic; just changes how the row is rendered.
Any unowned book can be starred as Wanted (☆ / ★ toggle on the Missing Works page and the author detail page). Wanted books sort to the top of Missing Works. Goodreads "to-read" shelf items are also set as wanted during import.
On the Missing Works page each book row has a ▼ button that expands a
file-candidate panel. Opening the panel fires a single request to
GET /api/books/{id}/file-candidates, which scans two sources and returns up
to 20 results ranked by Jaro-Winkler similarity
against the book's normalised title:
| Source | What it contains |
|---|---|
| Author files | LocalBookFile records already linked to the author in the DB but not yet matched to any specific book (the same rows shown on the author detail page as "unmatched") |
| Unknown folder | Files found on disk inside the __unknown quarantine bucket (not yet in the DB at all) |
Each candidate shows its filename, source, and match percentage. Two actions are available:
- Link — sets
BookIdon the existingLocalBookFilerecord (or creates a new one for unknown-folder files). The book is now owned and disappears from the missing list. - Link & Move (unknown-folder files only) — same as Link, but also
physically moves the file into
<primary library>/<Author>/<NormalizedTitle>/so it lands in the canonical library tree. TheLocalBookFilerecord is created with the new path.
The panel is lazy-loaded (fetched once on first open, cached for the session) and score-filtered at ≥ 40 % to suppress obviously unrelated files.
Not every book is on OpenLibrary — new releases especially. The + Add book button on an author's detail page (and the "add as a new book" action when resolving an unmatched physical row) catalogues one by hand.
A manual book behaves like any other — series, read status, ownership, cover —
but instead of a real OpenLibrary work key (OL…W) it gets a synthetic one
shaped XX + 8 digits + W. That marks it as not-yet-on-OpenLibrary: the UI
shows a small manual tag in place of the OpenLibrary link, and the OPDS
feed omits the (would-be-dead) OpenLibrary link for it.
When a later author works-refresh fetches an OpenLibrary work whose title
matches a manual book — exactly, or as a single clear ≥ 0.92 fuzzy match — the
manual row is promoted in place: it keeps its Book.Id (so its series
link, local files, read status and ownership all carry over) and only the work
key and OL-sourced fields (title, year, cover, subjects) are rewritten. An
ambiguous match promotes nothing, leaving a harmless duplicate to merge by hand.
The Manual Books page (/manual-books) lists every manual book across all
authors for review, edit, or deletion. Any book — manual or OpenLibrary — can
also be edited (title, year, author reassignment) or deleted from the author
page; deleting a book leaves its local files as unmatched rather than removing
them. Manual books have no OpenLibrary cover, so the edit dialog lets you paste
a cover image URL or pick one from a Google Books search.
The author detail page shows every local file that's in an author's folder but hasn't been linked to a specific work yet. Resolving them happens in three layers that compound rather than override each other:
- Author-prefix / suffix strip. Files named
<Author> - <Title>.epubor<Title> - <Author>.epubare rewritten to just the title before matching, using the author's known name variants (display name, library folder name, surname-first rotation, comma form). Without this,Terry Brooks - Magic Kingdom for Sale.epubwould never find theMagic Kingdom for Salebook. - Series-filename parse. When the stem matches the series grammar
(
<Series> N - <Title> [- <Author>]), the parsed title is added as a separate match candidate alongside the raw stem. - Fuzzy scoring on the unmatched list. The server returns the top three
Bookcandidates per unmatched file viaGET /api/authors/{id}/unmatched/suggestions, scored with Jaro-Winkler. A file is scored against the best of its author-free candidate titles (the stripped form and any series-parsed title) — never against a stem that still contains the author name. That matters because a book whose title starts with the author's own name (e.g. OpenLibrary's mis-titledAlan Dean Foster - Alien - Ala, or a realLonely Planet Scotland) would otherwise win on Jaro-Winkler's shared-prefix bonus for every<Author> - Xfile. Only candidates scoring ≥ 0.75 are offered, so an unmatched file with no real owning book shows no suggestion rather than a spurious 0.55 one. The Author Detail page renders the results as coloured chips (≥0.9 green, 0.75–0.9 neutral), with a one-click "Confirm N high-confidence matches" button that batches every ≥0.9 suggestion throughPOST /api/authors/{id}/unmatched/bulk-match.
If none of the local suggestions are high-confidence, the same unmatched-files
toolbar can fall back to filename-based OpenLibrary matching. That path
searches OpenLibrary using each selected file's filename/title folder and sends
the accepted result through
POST /api/authors/{id}/unmatched/openlibrary-bulk-match. If OpenLibrary says
the work belongs to a different author than the page you started from, the app
auto-creates or reuses that author, links the file to the fetched work, and
physically relocates the on-disk file into the target author's folder.
The same prefix/series/fuzzy pipeline is also used by the sync's automatic matcher so what you see in the UI mirrors how a sync pass would have evaluated each file.
What the unmatched list shows. Only actual ebook files appear — a row is listed only if it's a file with an ebook extension or a folder that contains one, so empty/stale folder rows (a bare title folder, or the author folder itself with a blank name) and archived copies are filtered out. The match dropdown and the suggestions also never offer a book you've flagged foreign or suppressed.
Per-file actions (besides Match and Return to incoming):
- Wrong author → unknown —
POST /unmatched/{fileId}/to-unknownmoves the file into the__unknownquarantine and drops its row, so it's re-evaluated with no author on the next sync. Use when it's filed under the wrong author. - Archive —
POST /unmatched/{fileId}/archivemoves it under the archive folder (restorable from Archived Files) and drops it from the list. Use for a foreign / unwanted file you've already decided not to link. - Delete —
DELETE /unmatched/{fileId}removes the file from disk and its row. Permanent.
Library folder names are normalized to lowercase alphanumeric + spaces, then
matched against the NormalizedTitle stored for each OpenLibrary work.
Multiple candidates are tried per folder in order; the first hit wins:
- Straight normalization —
The Hobbit (123)→hobbit - Trailing parenthetical stripped —
The Hobbit (J.R.R. Tolkien)→hobbit by Authorsuffix stripped (≥2 words required afterby, soStand By Meis never truncated) —The Hobbit by J R R Tolkien→hobbit- Both combined —
The Hobbit (2001) by J R R Tolkien→hobbit
Characters _, -, ,, (, ) are all treated as whitespace during
normalization, so The_Hobbit_by_Tolkien_JRR feeds the same pipeline.
Leading articles (the, a, an) are stripped, diacritics are decomposed,
and the trailing (id) numeric suffix is removed before any of the
above steps.
Suppressed / foreign books are never match targets. A book you've hidden
(suppressed) or flagged foreign is excluded from the auto-matcher's candidate
set and from the unmatched-file suggestions — so a junk book you've hidden
(e.g. a filename-derived Alan Dean Foster - Alien - Ala artifact) can't keep
having files auto-linked to it on every sync even though it no longer shows in
the book list.
TryParseSeriesFilename recognises a range of common naming conventions used
by libgen and various ebook tools so the series organiser
can shelve a file under the right series folder even when the DB has no series
metadata yet. The examples below are intentionally synthetic / anonymised, but
they preserve the exact filename shapes the parser understands
(case-insensitive, position-aware):
| Filename | Series | Position | Title | Author |
|---|---|---|---|---|
Deep Range 6 - The Last Beacon |
Deep Range | 6 | The Last Beacon | — |
River of Crowns 10 - Twilight Crossing - Rowan Hale |
River of Crowns | 10 | Twilight Crossing | Rowan Hale |
Vale, Mira - Deep Range 6 - The Last Beacon |
Deep Range | 6 | The Last Beacon | Vale, Mira |
Galaxy Patrol_ North Wing - 069 - Ember Protocol |
Galaxy Patrol_ North Wing | 69 | Ember Protocol | — |
Empire Cycle - 311 - Ashen Banner 03 - Hollow Sky |
Ashen Banner | 3 | Hollow Sky | — |
[Iron Lanterns 06.0] Final Signal - Arden Pike |
Iron Lanterns | 6 | Final Signal | Arden Pike |
Tessa Rowan - [Midnight Archive 05] - Silent Fracture |
Midnight Archive | 5 | Silent Fracture | Tessa Rowan |
Raven_ North Street Crew, Book 11 - Cold Mercy |
Raven_ North Street Crew | 11 | Cold Mercy | — |
Clockwork Bureau_ Volume 6 - Nina Sato |
Clockwork Bureau_ | 6 | Nina Sato | — |
Positions are normalised — leading zeros are stripped (069 → 69), .0
suffixes dropped (3.0 → 3), and fractionals preserved (1.5, 06.5).
Library (123) duplicate-ids and tool-added _2 / _3 suffixes are stripped
from the recovered title. Nested series resolve to the deepest unambiguous
match — Empire Cycle - 311 - Ashen Banner 03 - Hollow Sky picks the inner
subseries because it's more specific than the outer index. Bare parent indices
that look like authors (311, 008) are explicitly rejected as author names.
Coverage of these shapes lives in TitleNormalizerSeriesTests — 86 cases
spanning every pattern above plus negative examples that must return all-null.
Click any previewable format chip (epub, pdf, txt, rtf, mobi, azw, azw3, fb2, lit, docx, odt, cbz, cbr, zip) on an author detail page or the Untracked page to open an in-browser preview modal. EPUB, PDF and TXT render directly; any other supported ebook format is automatically converted to EPUB on the server first (ebook-convert) and then streamed to the EPUB reader — so you can read it in the browser without downloading or installing anything. The modal handles each format dynamically:
- EPUB — rendered with epub.js.
Has prev/next paging controls and uses byte-range requests so large books
don't pull into memory. Because epub.js parses content as strict XHTML/XML
(which predefines only
amp/lt/gt/quot/apos), the modal first rewrites HTML named entities — ,—,©, … — to their literal characters in each text document before rendering. Without this an otherwise readable book that uses such entities fails the XML parse with "Entity 'nbsp' not defined" and renders blank up to the first error. The integrity check correctly leaves these files marked OK — they open fine in lenient readers; only the strict in-browser parser needed the repair. - PDF —
<iframe>pointing at the streaming endpoint; the browser's built-in viewer provides paging, zoom, and search. - TXT — fetched and rendered in a serif
<pre>block with line-wrapping. Project Gutenberg-style plain-text books work without any conversion. - RTF — converted to plain text on the server with RtfPipe and shown in the same text pane. No Calibre needed; you see the readable prose rather than raw
{\rtf1 …}markup. - MOBI / AZW / AZW3 / FB2 / LIT / DOCX / ODT — converted on-the-fly to EPUB using Calibre (
ebook-convert) and rendered smoothly in the EPUB pane. - CBZ / CBR / Comic ZIP — extracts and renders images sequentially as pages directly within the modal. CBZ (zip) and CBR (rar) are both read natively via SharpCompress — no Calibre.
Security: the streaming endpoint validates that the resolved disk path
lives inside one of the enabled LibraryLocation roots before reading any
bytes. A tampered LocalBookFile.FullPath (e.g. one rewritten to point at
/etc/passwd) gets a 403 instead of leaking the file. The resolver is in
FilePreviewResolver.cs and is fully unit-tested.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/files/{id}/preview?format=epub |
Stream an EPUB (or convertible formats converted to EPUB) for in-browser rendering |
| GET | /api/files/{id}/preview?format=pdf |
Stream a PDF for the native viewer |
| GET | /api/files/{id}/preview?format=txt |
Stream a plain-text file |
| GET | /api/files/{id}/preview?format=rtf |
Convert the RTF to plain text (RtfPipe) and return it as text/plain |
| GET | /api/untracked/preview?format=… |
Same modal, but resolves the path through ResolveUntrackedSourcePathAsync so files inside the quarantine bucket (which have no LocalBookFile row) can also be previewed — the custom unknown path is added to the allowed-roots list so files outside the library locations still pass the safety check |
On the Settings page, add any number of NZB sites using URL templates. Three placeholders are resolved client-side per book:
| Placeholder | Resolves to |
|---|---|
{Title} |
URL-encoded book title |
{Author} |
URL-encoded author name |
{SearchTerm} |
URL-encoded "Author Title" combined |
Example template: https://nzbgeek.info/geekseek.php?q={SearchTerm}
On each author's detail page, unowned books show a link per active site. Sites can be reordered, toggled active/inactive, and deleted from the Settings page.
Each author carries a priority field (0–5 integer, displayed as stars). Zero is a valid deliberate rating ("lowest priority"), not "unrated". Priority is visible and editable on the author list and detail pages and is available as a sort/filter dimension on the list.
Starred authors (priority ≥ 1) bypass the English-only language filter so works in any language are retrieved.
The author blacklist (AuthorBlacklist table) prevents a library folder
from ever being promoted to a tracked author. Blacklisted entries are matched
by normalized name at scan time. Blacklisted authors that are already tracked
are silently skipped when processing their works. Exception: if a folder
for a blacklisted (or excluded) name is actually present on disk during sync
reconciliation, its files are real, so the blacklist entry is removed (and any
matching author un-excluded) and the folder is kept — an author whose files we
hold is never blacklisted or excluded.
The Duplicates page's Unlink action doesn't just detach a wrongly-matched file
from a book — it permanently blocks that exact (file path, book id) pair from
ever being re-linked, so a bad match can't keep silently coming back. Recorded in
the BlockedBookLink table, keyed by the file's FullPath (so the block survives
the file being unmatched and rescanned) plus the BookId. The per-group Unlink
all button does the same for every file in a duplicate group at once — for the
case where the whole group (not just the extras) turns out to be the wrong book
entirely — via action: "unlink" on the same bulk POST /api/books/duplicates/actions
endpoint used by Archive/Delete extras; each file is blocked and detached
independently, so a partial failure never leaves some files silently un-blocked.
Every automated matching path checks this before setting BookId (LinkBlocklist):
the classic sync title-matcher (preloaded once per sync — a per-file query would be
too slow at scale), assign-authors / content-scan's file-then-assign flow /
llm-identify's assignment (all share UntrackedAuthorAssigner), resolve-works'
ISBN linking, and llm-title-match's catalogue matching. The manual/bulk apply
flows (per-row Apply, apply-all, apply-isbn-all, "Find on OL", ISBN-mismatch
Reassign) check it too — deliberately not exempted, since re-picking the same wrong
result by hand is exactly the mistake this exists to catch. A blocked automated
match is treated as "nothing found" (falls through to the next candidate or leaves
the file unmatched); a blocked manual attempt returns a clear rejection explaining
why. Direct manual dropdown-pick actions where a human explicitly chooses the
book (/unmatched/{id}/match, bulk-match, same-name adopt, /books/{id}/link-file)
are not blocked — those are full manual overrides, not algorithmic matches.
Settings → Blocked book links lists every block (file, book, author, date) with a Remove button — the only way to lift one, e.g. if the original unlink turns out to have been a mistake.
OpenLibrary often has the same person split across multiple author rows. On each author's detail page, Link to another author… opens a search modal that targets the tracked watchlist and lets you choose one of two modes:
- Duplicate —
IsPenName = false. The child row is hidden from the main Authors list, its books are folded into the canonical's detail view, and its on-disk files are physically moved from the child's library folder into the canonical's. The merged book counts on the Authors list reflect both. - Pen name —
IsPenName = true. Both authors stay independent and keep their own pages and files. Each page just shows a "Pen name of X" banner back to the canonical.
Both modes are reversible from the same banner (Unlink); unlinking does not move files back to the child's folder — they stay wherever they currently are.
The link relationship is one-deep (no chains): you can't link a canonical that's already linked, nor a row that already has its own linked children. This keeps the merged-view query simple and predictable.
Every endpoint that handles "books for this author" honours the merge:
- The full sync's file-to-book auto-matcher pulls non-pen-name children's books
into the candidate set for the canonical, so files dropped under the
canonical's folder match titles that still carry the child's
AuthorId. AuthorRefresherskips its OL-collision auto-merge for any row with a user-set link, so a scheduled refresh can't silently delete a child you intentionally linked.- The series picker on the canonical lists series belonging to any folded-in child.
- New OpenLibrary works added for a child author (via its own OL key) flow
into the canonical's view automatically — the book is inserted under the
child's
AuthorIdand the merge does the rest at display time.
Author-name works are ignored. OpenLibrary returns, for nearly every author,
a "work" whose title is simply the author's own name (a profile / disambiguation
artifact). It is never a real book, so the refresh skips it on creation and,
for authors refreshed before this rule existed, deletes the existing phantom
— unmatching any files that were wrongly attached to it (they return to the
author's unmatched pile rather than being orphaned). Only a book the user
explicitly marked owned by hand is left alone. Such books are also never
offered as a match candidate on the author page. To clear them across the whole
library at once rather than waiting for each author's next refresh, use
Remove author-name phantom books on the Settings page (same rules).
POST /api/authors/cleanup-name-books.
After each refresh, an author's next scheduled fetch is placed in one of four buckets based on their most recent publication year. The default bucket lengths are editable on the Settings page and stored in the database; the built-in defaults are:
| Most recent work | Interval |
|---|---|
| Within last 2 years | 2 days |
| 3–5 years ago | 14 days |
| 6–10 years ago | 28 days |
| Older / no works | 60 days |
A fixed refresh interval can be set per author from the author detail page. When set, it overrides the calculated cadence — useful for very active authors you want checked daily or long-dormant ones you only want checked monthly. Set to blank to revert to the calculated interval.
The refresh-due-works scheduled job refreshes every author whose
NextFetchAt is due. Two limits — set on the Settings page under Works
refresh limits, stored in the database — govern the rest:
- Max authors per run caps how many authors are refreshed in a single run;
0(the default) means no limit. - Pull early when none are due — when no author is actually due, this many
of the soonest-due authors are refreshed early so the run still does useful
work (default
200;0disables early pulls).
The Settings page also exposes Duplicate format preference — a
semicolon-separated priority list such as epub;pdf;azw3;mobi. The Duplicate
Files page uses that order to decide which format is the recommended copy to
keep when the same work has multiple local files. A copy flagged damaged by
the integrity check never wins the keeper slot over a
healthy one, regardless of format — so the recommended copy is always one that
opens. Each copy's status (✓ ok / ✗ damaged / ? unchecked) is shown inline.
Only real copies are counted: a row whose path is a folder counts as a copy
only when that folder actually holds an ebook file, so empty/stale title-folder
pointers (left behind after a file was moved or archived) don't show up as bogus
duplicates. This "is it a real ebook?" check is the single source of truth
(AuthorsController.ResolveLocalCopy) applied on every read path — the author
page's book list, for instance, will never mark a book owned, or show a
"file", for a row that actually points at an empty folder, regardless of when
the cleanup job last ran.
The Settings page also exposes Duplicate content match threshold
(DuplicateContentMatchThreshold, default 90%) — the minimum text
word-overlap percentage the verify-archive-duplicates scheduled job requires
from every extra copy before it will archive them; see that job's row in the
Scheduled jobs table for how it decides "matches".
The prune-stale-files scheduled job (default 0 20 * * *, also runnable on
demand from the Sync page's Background jobs list) keeps the database and disk
in step:
- It sweeps the leftover folder-pointer rows out of the database entirely — only ever touching folder-shaped rows, and only when the library root is actually mounted, so a file row or a transient mount outage can never cause a deletion.
- It then removes every recursively-empty directory under each mounted root (bottom-up) — the title and author folders routinely left behind after a move, dedupe, archive, or assign. It only ever deletes a directory that contains no files anywhere beneath it, so no book or cover is ever at risk; the library roots and the configured quarantine / archive / incoming folders are protected even when momentarily empty.
Archived files are inert. Once a copy is moved to the archive folder
(DedupeArchiveFolder, default __archive) — via the Duplicates page, the
Damaged page, or the integrity job — no background job may touch it again
until you explicitly restore it from the Archived Files page. The library scan
does not descend into the archive folder (so archived files are never
re-indexed), and the cleanup passes never delete an archived row (so the Archived
Files page always has its records). Every job that moves or deletes files — the
series organiser, the author-folder disambiguator, linked-author merges, the
duplicate-author dedupe — skips archived paths through a single shared rule
(ArchivePolicy). This is what stopped previously-archived duplicates from being
quietly dragged back into a live author/series folder and reappearing on the
Duplicates page every few days.
For that exclusion to work, the archive actions must store the moved row with a
forward-slash path — the whole library lives on a Linux mount and every stored
path is forward-slash, so the archive prefix/segment is matched on /. The archive
write path therefore builds the destination with explicit / joins (never
Path.Combine, which emits \ on a Windows host); a back-slashed row would silently
fail the exclusion and the archived copy would keep showing as a duplicate.
Finally, a scheduled OpenLibrary metadata cache job can backfill missing
subjects and locally cache large cover images for existing works. Cached covers
are written under wwwroot/cached-covers/ and the corresponding Book.CoverUrl
is pointed at that local file so the UI can keep rendering covers even when the
remote OpenLibrary cover endpoint is slow or temporarily unavailable.
A drop folder (configured on the Settings page) is where new files land before they're slotted into the library.
-
Process incoming — reads each file's metadata (Dublin Core for EPUB, OPF sidecar, format-specific headers for the rest, or a
Author - Title.ext/Title - Author.extfilename fallback), maps it to a tracked author, and moves the file under<primary library>/<Author>/<Title>/…. -
Junk file deletion — files with extensions that are definitively not books or archives (
.xml,.inf,.nfo,.db,.ini,.url,.lnk,.tmp,.exe,.bat,.html,.log, etc.) are deleted immediately on encounter, before any matching attempt. Cover images (.jpg,.jpeg) and OPF metadata sidecars are also deleted. -
Author matching runs two indexes in priority order: the watchlist (tracked
Authorrows) first, then the seeded OpenLibrary catalog (OpenLibraryAuthor). Either kind of match routes the file to<primary library>/<AuthorName>/<Title>/— OL-only matches then appear in the UI's "unclaimed" list so you can promote the author to the watchlist with one click. Files that match neither go to__unknown/<top-level source folder>/— only the first path segment of the source layout is kept (it carries the author-name signal for a later reprocess); deeperTitle/...nesting is never recreated, so files sit directly inside their quarantine folder the way the flatten-unknown job expects. Filename collisions caused by collapsing subfolders are resolved with_Nsuffixing, never by overwriting. -
Reprocess __unknown — re-runs the author-matching pass against everything already sitting in
__unknown, then delivers each file to its author's folder (creating the author as Pending when needed). Files that still can't be resolved stay put. It works through a cascade of resolution tiers, each more permissive than the last, so the bulk of the quarantine clears even though most of it is indie/self-published books OpenLibrary has never heard of:- Tracked watchlist author (metadata or filename → an author you follow).
- OpenLibrary catalogue — the author exists in the seeded OL dump; a Pending author row is auto-created and the file delivered.
- Metadata + filename corroboration — the file's embedded author and its filename independently name the same plausible person. Two independent sources agreeing stands in for catalogue backing, so a KU author OL has never indexed still resolves. A Pending author is created.
- Repeated-filename corroboration — for DRM'd files with no readable
metadata at all, a plausible author name that appears on two or more
distinct files in the quarantine is corroborated by repetition. Known
series names are vetoed (a series suffix repeats across files exactly
like an author would), as is the blacklist. A single uncorroborated
filename name is not enough — it stays in
__unknown.
Across the live ~29k-file quarantine this lifts author determination from ~22% to ~79%; the rest are genuinely nameless (bare titles, numbered chapter dumps, junk like
autoexp_dat.txt). -
Author probes — beyond the metadata author and the plain "Author - Title" / "Title - Author" filename splits, matching also probes each individual author of a joint credit ("A & B", "A; B" — one EPUB dc:creator / MOBI EXTH field often carries both) and every filename interpretation from the same parser the content scan uses ("Title by Author", "[Series NN]" tags, "et al", "(mobi)" tags, "Last, First" inversion).
-
Folder-layout matching — if a file's metadata is unreadable but any ancestor folder name matches a tracked author, the whole folder is treated as
<Author>/<Title>/<files>so multi-format books stay together.
By default each library location keeps its own __unknown/ subfolder for
unmatched author quarantine. Set Unknown (quarantine) folder on the
Settings page (key AppSettings["UnknownFolder"]) to consolidate every
quarantined item under one absolute path instead — handy when you want
quarantine on a different drive, share, or outside the scanned library tree
entirely.
Saving the setting migrates contents in the same request: every existing
<library-location>/__unknown/<author> folder (or, if a previous custom path
was already set, every folder under that path) is moved into the new path
with _N suffixing on collision, and matching LocalBookFile.FullPath rows
are rewritten so on-disk and in-DB state stay aligned. Clearing the setting
migrates everything back to the primary library location's default
__unknown/. The response reports foldersMoved / filesMoved / dbRowsUpdated
plus any per-folder warnings (e.g. cross-drive Directory.Move failures).
Every code path that touches the quarantine bucket goes through
UnknownFolderResolver — listing folders on the Untracked page, dispatching
sync's "untracked → quarantine" moves, the reprocess-unknown job, the
flatten-unknown job, and the unzip job's quarantine-archive pass — so the
override applies everywhere without scattering branches across the codebase.
AuthorMatcher indexes every tracked author and (where applicable) every
OpenLibrary catalog row under multiple key variants so name spellings, sort-
order forms, and alternate names all resolve to the same entry:
- Normalised display name —
Arthur C. Clarke→arthur c clarke Last, Firstform —Clarke, Arthur C.→arthur c clarke- Surname-first rotation —
arthur c clarkealso indexes asclarke arthur c - First-token-to-back rotation (3+ tokens) —
c clarke arthur - Library folder name — same set of variants applied independently
- OL
alternate_namesandpersonal_name— when anOpenLibraryAuthorrow exists for the tracked author's OL key, every entry fromAlternateNames(semicolon-delimited) and thePersonalNameis indexed alongside the primary keys
Tracked entries win over OL-only entries on key collisions. The blacklist (normalised author names) is applied at index build time — blacklisted authors silently never match. Linked non-pen-name children are not added to the index — folders matching their name resolve to the canonical instead.
POST /api/unknown-folders/match (also exposed as the 🔍 Try matching all
button on the Untracked page) walks every folder inside __unknown across all
enabled library locations and runs each folder name through the matcher. Each
match physically moves the folder out of __unknown and into the canonical
author's folder (merging entry-by-entry if a folder already exists at the
destination). Use it after adding authors to recover quarantined collections
without a full sync.
The decision logic is split out as a pure function
(UnknownFolderRecovery.Plan) so the matching algorithm is unit-testable
without any disk I/O. The endpoint itself only does I/O and DB updates.
When two (or more) tracked authors share the same normalised name and are not linked to each other (no parent/child link, no shared canonical), they're treated as a genuine collision and given separate on-disk folders suffixed with their OpenLibrary key:
<Root>/John Smith_OL12345A/… ← author #1
<Root>/John Smith_OL67890A/… ← author #2
Both members of the collision get the suffix — never just one — so the layout is deterministic regardless of which row was added first. If any member of a group lacks an OL key, the rule waits until every key lands before suffixing (otherwise the layout would change shape on every refresh).
The rule is applied:
- On add (
POST /api/authors) — when adding a new author surfaces a name collision against existing rows. - On refresh (every per-author OL refresh) — picks up newly-resolved keys and new collisions as the watchlist grows.
- Via the maintenance job
disambiguate-folders— runs daily at 11:00 by default (managed on the Schedules page) and is also callable on demand from the Untracked page button "↔ Disambiguate same-name folders" or viaPOST /api/authors/disambiguate-folders.
The maintenance job does the heavy lifting on legacy data:
- Finds every group of 2+ unlinked authors sharing a normalised name where every member has an OL key.
- For each LocalBookFile in the merged folder, looks up its
NormalizedTitleagainst each member's books — the file moves to the matching author's suffixed folder. - Files whose title doesn't match any member's bibliography stay with the
lowest-id author so they remain visible (and re-matchable from the
author detail page) instead of being lost to
__unknown. - The on-disk move and DB rewrite (
LocalBookFile.AuthorId / AuthorFolder / FullPath,Author.CalibreFolderName) are done in lockstep so a partial failure leaves the system consistent.
The job runs through BackgroundTaskCoordinator like every other organiser,
so it can't overlap with sync, incoming, or series-organize runs.
OpenLibrary frequently splits one real author across several author records.
The same-name-authors scheduled job (every 6 hours by default) finds the
rest: for every author already on your watchlist it looks up the locally-seeded
OpenLibraryAuthor catalogue for records sharing the exact same normalised
name and adds any that aren't tracked yet, as Pending (a later refresh fills
in their works). It's a pure local DB lookup — no OpenLibrary API calls —
and skips blacklisted names plus any name so generic it matches more than 25
catalogue records. Review the additions and link them as duplicates or pen
names from each author's detail page.
The series organizer enforces a canonical flat-file layout across every tracked
library location. Books are filed under their direct series name (the leaf
series, not the parent). For example, a book in "The Belgariad" (a child of
"The Belgariad & Mallorean Saga") is filed under The Belgariad/, not under
the parent saga folder:
<Root>/<Author>/<Series Name>/book.epub (book belongs to a series)
<Root>/<Author>/book.epub (book has no series)
Title subfolders are eliminated — ebook files live directly in the series or author folder. On each pass:
- Every
LocalBookFilerecord is evaluated (starred authors first, then alphabetically by author folder). - Series resolution uses a three-step priority chain per file:
Book.SeriesId(FK to theSeriestable) — the user's explicit value always wins. A null FK means "not yet known" (fall through). An empty-string series name (user explicitly cleared it) sends the book to the author root.- Auto-clean bad stored values — if the stored series name itself looks like a title-folder string (
"Midkemia 02 - The King's Buccaneer"), the clean series name is extracted, the DB is updated, and the clean name is used for the move. - Filename fallback — when the DB has no series at all, the filename is parsed (
"Chaoswar Saga 03 - Title.epub"→ series"Chaoswar Saga", position3) and backfilled into the database.
- Files already at the correct location are skipped; their DB paths are updated from the legacy directory format to the actual file path if needed.
- Flat-file vs. classic layout — when
FullPathpoints to a file (flat-file), only that file is moved. When it points to a folder (classic library layout), all folder contents are moved together so nested structures collapse in one pass. - Junk files (
.xml,.inf, etc.) encountered during a move are deleted rather than copied to the target. - Source containers and their empty ancestors are pruned bottom-up after each move, up to (but not including) the author root.
LocalBookFile.FullPathis updated to the moved ebook file path immediately after each operation so a subsequent sync sees the correct paths.
Name conflicts at the destination are resolved by appending _N to the file
stem. Stale directory-pointer records (where another record already tracks the
target file path) are removed rather than producing a unique-index violation.
Every move verifies the source is gone and force-deletes any lingering
original. On the CIFS/NFS mounts this library lives on, File.Move can copy to
the destination but leave the source behind (a deferred or silently-failed
unlink); left unchecked, the next sync scan re-imports that orphan as a fresh
LocalBookFile and the book reappears as a duplicate with no new files added.
Confirming removal after each move closes that resurrection path.
The organizer also handles libraries recorded under Windows UNC paths
(\\server\share\…) when the server runs in a Docker container with the share
mounted at a different path — the \\server\share prefix is stripped to recover
the container-local path for all file I/O.
Scans all LocalBookFile records for .zip and .rar archives (starred
authors first). For each archive found:
- Extracts all files flat into the configured incoming folder (archive-internal subdirectories are stripped).
- Deletes the archive from disk.
- Removes the
LocalBookFilerecord from the database.
A second pass then walks every quarantine root (each library location's
__unknown/, or the custom path when set) and extracts any .zip/.rar
file living there. Quarantine archives have no LocalBookFile row (sync
purges them when moving folders to quarantine, and IncomingProcessor never
inserts rows for unmatched files), so the LBF-driven pass alone would leave
them sitting there forever. After extracting, any now-empty parent folders
up to the quarantine root are pruned.
The extracted files are then picked up by the next incoming processing run. Archives recorded under Windows UNC paths are remapped to the container mount path the same way as the series organizer.
The quarantine holds loose files only — never author or title subfolders.
The reprocess-unknown job re-derives each file's author from its name/metadata,
so folder grouping inside the quarantine carries no value and only clutters it.
Every path that relocates content into quarantine routes through a single
helper (UnknownQuarantine) that moves files to the quarantine root, so a
folder tree can never be (re)created under __unknown:
- Sync reconciliation — an untracked author folder in the main collection is
flattened into the quarantine root (its files moved loose), not moved as a
folder. (Moving the whole folder is what used to keep repopulating the
quarantine with
__unknown/<Author>/<Title>/trees every sync.) - "Wrong author → unknown" on the author page flattens a folder-shaped row's files into the root too.
- Incoming processing already drops unmatched files flat into the root.
- Changing the quarantine path (Settings) flattens existing content into the new root rather than moving author folders across.
Self-healing invariant. Because a folder in the quarantine must never
persist — whatever creates it (an older build, a misrouted move, a manual drop)
— every sync run ends by flattening any folder found under a quarantine
root: its files are lifted to the root and the folder removed. So even if some
path slips a folder in, the next sync (hourly) erases it. "No folders in
__unknown" is thus a continuously-enforced invariant, not something a single
code path can break. (The assign-untracked-books job files into the main
library folder — a library location, never the quarantine — so it isn't a
source of quarantine folders; the self-heal covers it regardless.)
Walks every folder under each quarantine root, moves all contained files
(recursively) up to the quarantine root, and removes the now-empty folder
tree — so any subfolders that pre-date this policy (or were created by an older
build) are cleaned up. Collisions are resolved with _N suffixing; a file that
can't be moved leaves its folder in place (never recursively deleted) for the
next run. LocalBookFile.FullPath rows are rewritten to the new flat path.
Enable it on the Schedules page (flatten-unknown job, default cron
0 9 * * *) — or run it once manually via Run now.
Off by default. Finds byte-identical duplicate files anywhere inside the
quarantine (__unknown/ across all enabled locations, or the custom quarantine
path) and deletes all but one copy of each. "Duplicate" means identical
contents, nothing less: files are grouped by size first (no file reads — cheap
on the NAS mount), and only same-size groups are read and SHA-256-hashed.
Different names with the same bytes are duplicates; the same name with
different bytes is not — both stay (the run logs a NearDuplicates count and
samples of "same name and size but different contents" so a zero-deletion run
is explainable). The kept copy is the one with the shortest full path
(alphabetical tiebreak), so un-suffixed originals win over _1-style
collision copies. Zero-byte files are deleted outright — an empty ebook is
junk, not a duplicate. DB rows pointing at a deleted path (the
UnknownFiles index, UnknownFileChecks, LocalBookFiles,
BookContentScans) are removed in the same run.
Enable it on the Schedules page (dedupe-unknown job, default cron
30 9 * * *) — or run it once manually via Run now or the Sync page's
Dedupe __unknown button (POST /api/jobs/dedupe-unknown/start).
On by default, daily at 04:00. For every author that has unmatched files, it deletes byte-identical duplicate copies within that author's own folder, keeping one. Scope is strictly per author folder — each folder is scanned in isolation, so two different authors who happen to hold the same file are never compared and nothing crosses an author boundary.
The duplicate determination is the same shared scanner as the dedupe-unknown
job (size-group → SHA-256 → one keeper; zero-byte files deleted as junk), so
"duplicate" means byte-identical and nothing less. The one difference is keeper
preference: a copy that's linked to a Book wins over an unlinked one (then
shortest path), so a matched book never loses its only file to a duplicate
deletion. DB rows (LocalBookFiles, BookContentScans) for deleted paths are
pruned. NAS-safe: aborts if no library root is mounted.
Run it manually via the Sync page's Dedupe author files button
(POST /api/jobs/dedupe-author-files/start) or the Schedules page's Run now.
On by default, daily at 07:30. Links every manually-catalogued book (synthetic
XX work key — hand-added entries and the placeholder books the series builder
mints) to its real OL work.
Phase 0 — DB only, no OpenLibrary. A manual placeholder that duplicates a real OL book already under the same author (same normalized title) is folded straight into that row — we already hold the canonical book, so no web search is needed. This is unbounded (no OL calls), so it clears the whole duplicate backlog every run regardless of the per-run cap. (On a large library the series builder mints these by the thousand, so this is the bulk of the work.)
Phase 1 — OpenLibrary search for the rest (capped — OL is rate-limited; the
cap is Settings → Background job run limits → Promote manual books, default
100). Books are taken oldest-checked first: each manual book carries a
persisted Book.PromoteCheckedAt timestamp (NULL = never checked) that is stamped
on every attempt, so the sweep advances through the backlog, survives restarts
(unlike the old in-memory skip set, which reset to the start on every restart),
and — once every book has had a turn — loops back to re-check the not-yet-listed
ones oldest-first. Settings → Background job run limits has a Re-check all
manual books button (POST /api/settings/reset-promote-checks) that clears the
timestamps to force a full re-sweep, e.g. after OL lists a batch of previously-
missing titles.
A match requires BOTH the title (normalized-equal or very close) and the
author (OL key when the watchlist row has one, else normalized name) to agree
— a title-only hit never rebinds a book to someone else's work. Promotion is
in place: the Book.Id is kept, so series link, position, read status,
ownership, files and custom cover all carry over; only the OL-sourced fields
(work key, canonical title, year, cover) are refreshed. When the author
already has a row for that OL work, the manual row is merged into it
instead: series/position fill blanks on the target, ownership/read-status/
wanted carry over, file links (including omnibus references) follow, and the
manual duplicate is deleted — no series info is ever lost.
Off by default. The check-integrity job opens every matched ebook file and
verifies it's actually readable and substantial — catching truncated downloads,
corrupt archives, DRM-locked files, and near-empty placeholders that otherwise
sit in the library looking fine.
For each in-scope file (a LocalBookFile linked to a book whose extension is an
ebook format — epub, pdf, mobi, azw/azw3, fb2, cbz/cbr, lit,
djvu, doc/docx, rtf, txt) the check is:
- PDF — opened with PdfPig; the real page count must be ≥ 20.
- EPUB — the zip,
container.xml, OPF package and spine must all parse, and the spine's combined text must estimate to ≥ 20 pages. EPUB has no fixed page count, so pages are estimated from readable text length at ~1,024 characters per page (the figure Adobe uses); markup is stripped before counting. Spine documents are resolved tolerantly — URL-encoded hrefs, OPF-relative paths,#fragments, and case differences are all handled, with a fallback that tallies every content document in the archive — so a perfectly readable book is never mis-scored as empty just because of an href quirk. - RTF / FB2 / DOCX / ODT — text-extracted natively (no Calibre) and
page-counted from the text the same way as EPUB: RTF via
RtfPipe; FB2 (FictionBook XML), DOCX and
ODT (zip-of-XML) with the BCL, reusing the same parsing as their metadata
readers. A file that yields no readable text is flagged damaged. Native
extraction is milliseconds per file (vs. spawning
ebook-convert) and works even when Calibre isn't configured. - CBZ / CBR (comics) — read natively via SharpCompress (zip and rar); the "page count" is the number of image entries. An archive that can't be opened is flagged damaged.
- TXT (plain text) — already readable, so it is checked natively with no
conversion: at most one page-floor's worth of characters (20 × ~1,024) is read
in a single block and page-counted, then reading stops. A multi-megabyte text
file is therefore inspected as cheaply as a tiny one — no
ebook-convert, no full-file read — which is why.txtis no longer slow to scan. - Everything else (MOBI / AZW / AZW3 / LIT / DJVU / DOC) — converted to EPUB
with Calibre's
ebook-convertfirst (these are proprietary binary formats with no good native reader), then the EPUB check is applied. A conversion failure marks the file damaged. If Calibre isn't configured the file is skipped (left for a later run) rather than wrongly flagged.
Any file that can't be opened/converted, or falls short of 20 pages, is flagged
and listed on the Damaged page (/damaged), grouped by book like the
Duplicates page so every bad copy of a title sits together. (Archived files are
excluded from the list automatically.)
Each book group has a header showing the title, author, bad-copy count, and:
- NZB search links — one per active configured NZB site,
built from the book's title + author with the same
{Title}/{Author}/{SearchTerm}substitution used elsewhere, for hunting down a clean replacement copy. - Want — flags the book as Wanted so it shows on the Wanted page for re-acquisition.
- Archive all bad copies — moves every damaged copy of that book into the
dedupe archive folder (
DedupeArchiveFolder, default__archive) in one action — recoverable from the Archived Files page, exactly like the Duplicates page's archive.
Each file row within a group offers:
- Preview — opens the same in-browser preview modal as the rest of the app (EPUB / PDF / TXT / RTF / CBZ, plus Calibre-convertible formats) so you can see the file for yourself before deciding.
- Mark OK — clears the flag for a false positive. The file leaves the list and won't be re-flagged until it actually changes on disk (see below).
- Recheck — re-queues just that file for a fresh check on the next run.
- Remove — permanently deletes the file from disk and drops the record (the hard-delete counterpart to the group's recoverable archive).
- Replacements — expands a panel listing every other file linked to the same
book, including copies in the archive folder. Each copy shows an
archivedtag (when applicable) and its own integrity status (ok/damaged/unchecked), so you can see whether an archived candidate has been checked and is healthy before restoring it. Each offers Restore & replace, which moves that copy into the damaged file's place, re-queues it for a check, and deletes the damaged file in one step.
The page header has:
- Check now — runs the job on demand.
- Recheck all — clears every flag and re-evaluates the whole list on the next run; handy after an integrity-checker fix, since files flagged by the old logic won't otherwise be re-checked until they change on disk.
- Archive damaged with a good copy — bulk action that archives every damaged
file whose book already has a healthy live copy in one of the configured
replacement formats (Settings → Book integrity check, default
epub;mobi;lit, keyIntegrityReplacementFormats). Damaged files with no such replacement are left untouched.
Incremental and cheap to re-run. A file is only (re)checked when its stored
fingerprint — LocalBookFile.SizeBytes and ModifiedAt — differs from the
values recorded at its last check, or it has never been checked. That's a DB-only
comparison, so the candidate scan does no disk I/O on the library mount, and a
file you Mark OK (which stamps both values) stays OK until its size or
modified time actually changes. Each run processes at most Max books tested
per run files (Settings page, default 200), working through the backlog over
successive runs. Candidate priority is: unarchived before archived, and within
each, starred authors (higher Author.Priority) first — i.e. starred-unarchived
→ unarchived → starred-archived → archived. (An archived copy is already out of
the live library, so its health matters least.) Enable or schedule it on the
Schedules page (check-integrity, default cron 0 12 * * *).
Results persist per file, not in batches: a single check can take minutes
(a .lit/.mobi conversion runs up to the 10-minute Calibre timeout), so a
restart, redeploy, or cancel mid-run keeps every result already produced — the
next run picks up where this one stopped instead of re-checking the same head
of the backlog. Error messages are capped to the column length, and a row that
can't be saved is logged and detached rather than aborting the run.
Whenever a run touches a file that belongs to a book, it also folds in every other still-unchecked file of that same book/title and checks them in the same run — even past the per-run cap. Checking all copies of a title together is what lets the Duplicates page choose a healthy keeper, so the few extra files are worth it; the displayed "checked" count grows to include them.
Content check folded in. A healthy file the integrity job just opened is already warm, so the run also performs the content check on it then and there rather than re-reading it in a separate content-scan run later. For an unmatched file that means the full author/title/series guess; for a matched book (title/author already known) it harvests just the series catalogue from the book's "Also by" pages — which is how series data gets built up from the whole owned library, not only from unmatched files. It records the result against the file's size (so it isn't repeated until the file changes) and never lets a content-extraction hiccup disturb the integrity result.
Scope and auto-archiving. The check covers every ebook file linked to a book or (unmatched but) to an author. A damaged file that's unmatched but author-linked isn't shown on the Damaged page (there's no book to triage it against) — it's just a bad orphan, so it's archived automatically.
Only rows that are actual ebook files (by extension) appear on the Damaged
page. Directory-shaped / extensionless rows are never checked, and any such row a
much-older check left flagged is un-flagged at the start of each run (it could
never be re-evaluated, so it would otherwise sit on the Damaged page forever as an
un-previewable ". file").
Once every author-linked file is done, a second phase checks the untracked
files in the __unknown bucket (the UnknownFiles index). A damaged untracked
file is likewise archived (author unknown — the move just preserves its
library-relative path); healthy ones are recorded in a small UnknownFileChecks
table so they aren't re-checked every run (that table survives the UnknownFiles
re-index that sync performs). All auto-archiving is NAS-safe: a file is only
moved when it sits under an enabled library root.
The job also appears in the Sync page's Background jobs list, with a manual
start button and live progress while it runs — each tick names the current
file, e.g. Checking 140/201: Anne McCaffrey — The Ship Who Sang (lit).
For files that aren't matched to a book — unmatched files (a LocalBookFile
with no book) and untracked files (the __unknown bucket) — the
content-scan job reads both the front matter and the back matter (up to
~40k characters from each end) and guesses the author, title, series, any
"also by this author" list, and a structured series catalogue. Reading
both ends matters: the title/copyright sit at the front, but the "Also by /
Novels by / Other books" bibliography and series listings live in the back of
the book at least as often as the front, so a head-only read missed most of
them. It reuses the same text extractors as the integrity check (EPUB / PDF /
FB2 / DOCX / ODT / RTF / TXT natively; MOBI / AZW / LIT via Calibre; image-only
formats are skipped) — EPUBs and PDFs are opened once for both ends, not
parsed twice — then applies heuristics: ISBN (copyright page),
Title:/Author: headers (Project Gutenberg), "Copyright © YEAR by NAME",
"Also by / Novels by NAME" lists (every such block, front and back, merged and
deduped), and "Book N of the X" series lines. A captured author name is trimmed of
copyright-notice boilerplate the name pattern can run into on a single line —
"Copyright 2020 by John Smith. All rights reserved" yields John Smith, not
"John Smith. All" — while genuine initials (J.R.R. Tolkien) are preserved. A "Book N of the X Series" line
feeds the series + position only — it is never taken as the title (so a
position descriptor can't become an unmatchable guessed title like "Book 2 of the
Sword Dancer Series"); if a real title sits just above it on the title page, that
title is used instead. List parsing keeps only genuine
title-cased lines and stops at section headings (Warning, Dedication,
Acknowledgements, About the Author, …), so a hard-wrapped dedication, warning or
epigraph isn't slurped in as fake titles — a real problem for plain-text books
where every prose line is short. Leading bullets and explicit list numbering
("1. ", "2) ") are stripped from list entries, but digits that are part of a
real title ("3:10 To Yuma") are kept. Author capture survives the messy forms
found in real front matter: a title page byline with a generational suffix
("By W. M. Quiller, Jr."), a single-line "Title by Author" page, and
multi-author credits (the first author is kept whole instead of the old
truncated "X and Thomas"). Publishers ("… Media", "… Press") and URL fragments
are refused as authors, and a "Book N in the X series" mention inside running
prose can no longer smear a whole sentence into the series name.
Title blacklist. Whatever guessed a title — prose heuristics, embedded
metadata, or the filename — the result is checked against the title
blacklist (TitleBlacklist table, Settings → Blacklisted book titles,
above the author blacklist) before it's stored: an exact match (by
TitleNormalizer.Normalize — case/punctuation/leading-article insensitive, not
a substring) is refused and the title left null instead. This catches
copyright-page boilerplate and OCR artefacts that otherwise look like a
plausible title-cased line ("Scanned", "Published", "@page", "A Novel", "A Del
Rey ® Book Published") and would reach OpenLibrary search as if they were
real. Seeded with a starting set of known junk strings; add or remove entries
from the Settings page.
Embedded metadata comes first. Before trusting prose heuristics, the scan
reads the file's own embedded metadata (EPUB OPF dc:creator/dc:title, MOBI
EXTH, FB2, PDF info, DOCX/ODT core properties — the same readers the incoming
processor uses, via the shared FileMetadataReader). When the metadata author
validates against the catalogue (an OpenLibrary-catalogue or watchlist
name, in either "First Last" or "Last, First" orientation, never blacklisted —
AuthorNameValidator), the metadata author and its full title replace the
prose guesses. This matters enormously for the quarantine: its filenames are
typically truncated to ~30 characters ("Rescued by the Mountain Man_ An -
Nyla Lily.epub") while the OPF inside carries the complete title — and the
assign-authors job's OpenLibrary work search needs the full title to land on
the right work. Swapped or junk metadata ("Dark Prince" as the creator) fails
validation and falls through to the prose/filename guesses. The same
metadata-first rule applies again at assignment time: the assign-authors
job re-reads the embedded metadata of each file it's about to file and, when
the author validates, searches OpenLibrary with that author + full title
instead of the (possibly older, filename-derived) scan guesses. Metadata
fields containing control characters (binary garbage from a corrupt
MOBI/PDB header) are dropped at the reader, and every OL search input is
guarded the same way — a %00 query gets 403'd by OpenLibrary's WAF and used
to fail the whole assign request.
Filename parsing backs the content read up. Untracked files are often DRM'd or prose-from-line-one, yielding no content guess at all — but their names plainly carry the answer ("Title - Author", "Title by Author", "Last, First - Series NN - Title", "[Series NN] - Title - Author", "Title_ Subtitle - Author" with "_" read as the sanitised ":"). Placeholder segments (" - Unknown", "- Anonymous") are dropped before interpretation and never offered as author candidates (the OL catalogue contains literal "Unknown"/"Anonymous" author records, so an unfiltered placeholder would wrongly validate). Beyond the " - " splits, the parser also reads an author from trailing parentheses ("Inferno (Troy Denning)"), probes smashed-together names with no separator at all ("Almuric Robert E. Howard" → trailing/leading word groups as the author), splits on a hyphen without spaces ("Charles L. Harness-Lethary Fair"), applies the "by Author" split inside any segment ("02 - The Cloud-Sculptors of Coral D by J. G. Ballard"), strips "(ebook)" / "(ebook by Group)" release tags (even truncated mid-word by the 30-char rename) and "(ed)" editor credits, and offers every co-author of a joint credit ("Adriana Campoy & James P. Blaylock" — the second name may be the catalogued one). Since a filename can't say which side is the author, every plausible interpretation is generated and the first whose author matches the OpenLibrary authors catalogue (or an existing watchlist author, never a blacklisted name) wins — so "Author - Title" vs "Title - Author" disambiguates itself and garbage can't get in. Catalogue validation also bridges initials-run spellings ("CS Lewis" matches the catalogue's "C. S. Lewis"), spelled-out forenames ("Lyman Frank Baum" matches "L. Frank Baum"), and — after the next OL reseed re-normalizes the catalogue — stroke/ligature letters ("Stanislaw" matches "Stanisław"). This runs for new scans AND as a cheap DB-only pass at the start of every content-scan run that back-fills author/title onto existing guess-less untracked rows (filename guesses never overwrite content-derived ones).
When a scan guesses an author name, every OpenLibrary author with that (normalised) name is pre-provisioned as a Pending watchlist row so it's selectable on the Identified page without a manual lookup — unless the name is on the author blacklist, which a content guess must never resurrect.
The flat "also by" list is review context only (shown in the Also by column); the part that actually builds series is the structured series catalogue (a list grouped under a named series header). A bare also-by list with no series header therefore populates the column but won't, on its own, create a series.
Stale guesses are pruned. Scan rows are keyed by file path, and files move
(flatten, return-to-incoming, manual deletes, accept-author). Every content-scan
run starts with a DB-only pass that removes unreviewed rows whose path is no
longer claimed by any index (LocalBookFiles, or the UnknownFiles quarantine
index that sync rebuilds from disk) — so the Identified page stops showing
entries for locations files are no longer at. Reviewed rows survive even when
stale, since catalogue-carrying ones still feed apply-catalog; a moved file's
new location simply gets scanned fresh as its own row.
The job works through files in priority tiers, each filling whatever capacity the previous left — matching the job's aims:
- Unmatched books in an author folder → identify the right book. Starred
authors first (by
Author.Priority), as everywhere else. - Untracked
__unknownfiles → identify the author. - The rest — unmatched files not in any author folder.
Series catalogue. Many authors print a grouped bibliography — a heading
("Novels by G R Jordan"), then for each series a header line (often with a
(Genre)), a blank line, and the titles in that series, with blank lines between
series:
Novels by G R Jordan
The Highlands and Islands Detective series (Crime)
Water's Edge
The Bothy
...
Kirsten Stewart Thrillers (Thriller)
A Shot at Democracy
...
The parser reads through the blank lines, recognises each series header (by its
(Genre) suffix or a trailing collection word like Chronicles / Thrillers /
saga, dropping a generic trailing "series" descriptor from the name), and
attributes every title to the series it sits under. The result is stored as JSON
on the scan row (SeriesCatalogJson) — an array of { Series, Genre, Titles[] }
— so the series data can be built up automatically from what books say about
themselves. It's shown as an expandable Series catalogue column on the
Identified Books page for you to confirm.
A Build series button on that column applies the catalogue (confirm first).
It works across all of the author's scanned books, not just the one row:
each book typically lists the series up to its own point (book 3 lists 1–2,
book 36 lists 1–35), so every catalogue for that author is merged into one
consensus order — the longest list reconstructs the full ordering and the
shorter ones corroborate it. It then creates — or reuses an existing series of
this same author (matched by normalized name; a different author's
identically-named series is never reused, or this author's books would land in it
and no series would appear under this author) — a Series per listing (always
setting its primary author) and assigns the
author's owned books their catalogue position. A generic catalogue header
that names a category rather than a real series — Novels, Short Stories,
Other Books, … — is qualified with the author name ("Novels" → "Anne
McCaffrey Novels") so it becomes a distinct, author-specific series instead of
colliding with (and being merged into) every other author's identically-named
bucket — which is why such a series previously seemed to "vanish" after building. Matching is exact on the normalized title first, then fuzzy
(Jaro–Winkler ≥ 0.88) so small spelling/subtitle differences still link, and
each owned book is claimed at most once. A book with no series is filled in;
a book already in that same series has its position corrected; a book in
a different series is left untouched (existing curation is never clobbered).
A catalogue title the author doesn't own is added to the series as a
placeholder member — a not-yet-owned book with a synthetic manual work key
(XX…W, preserved across OpenLibrary refreshes), shown as a "missing" entry on
the Series page — so the series keeps its full title list and ordering rather
than just the few titles already on disk. The
catalogue-only rows that were consumed are cleared from the review list. The
button only appears when the file is linked to an author. POST /api/identified/{id}/apply-catalog. A page-level Build all series button does
the same for every author listed at once (POST /api/identified/apply-catalog-all) — series only; it never touches the book-title
or author guesses. Because the whole library can be tens of thousands of authors,
this runs in chunks (the client calls the endpoint repeatedly with
afterAuthorId/batch, showing live progress, until the response says Done) so
it can't blow the reverse-proxy request timeout. Each author is built and
committed independently inside its own try/catch: one author whose catalogue
clashes with existing data is recorded in Failures (with a plain-English reason)
and skipped, instead of aborting the whole run — and the final dialog reports how
many were skipped and why. Re-running is safe (already-built series are reused),
so an interrupted run just continues where it left off.
Guesses are written to a BookContentScan row and surfaced on the Identified
Books page (/identified) for you to review. A row only appears when it adds
something actionable — an ISBN, title, series, "also by" list, or series
catalogue. An author-only guess is shown only for a file that isn't already
filed under an author (an untracked __unknown file, where the author is the
one useful lead); for a file already sitting in an author folder a bare author
guess just re-confirms what's known, so it's hidden rather than cluttering the
list with "accept author" rows that do nothing. The list is split into two
sections: Untracked (__unknown files — the small, high-priority set,
returned in full up to a 10,000-row safety ceiling) and Tracked (files
already in an author folder — can run into the tens of thousands). The Tracked
section is paged server-side, 100 rows per page, newest-scanned first
(GET /api/identified?trackedPage=0), with Prev/Next controls above the
table; bulk actions (Apply all, Dismiss all, …) only ever act on the rows in the
currently loaded page. A single filter box narrows
the list to rows whose text matches in any column — path, author, title,
series, ISBN, "also by", or anywhere in the series catalogue; a Starred authors
only toggle further limits the list to files filed under a priority
(Priority >= 1) author. An ISBN status dropdown filters on where each
row's ISBN guess stands. Every ISBN-bearing unmatched row lands in exactly
one of three buckets — no silent 4th "excluded" state: Waiting for ISBN
match (Apply has nothing to use yet, for any reason — never attempted, a
confirmed miss, or a fallback-provider title with no OpenLibrary work key —
these are lumped together because from Apply's perspective they're all
equally "not usable," and splitting them out just made the count of what's
actually still outstanding look artificially small); ISBN matched
(resolved to a work whose author agrees — Apply would succeed); and Needs
reassigned to other author (resolved to a work by a different author — Apply
refuses it; use ↪ Reassign to «author» instead). This is computed
server-side from the already-cached IsbnResolutions table in a couple of
bulk queries (GET /api/identified?isbnStatus=…) — deliberately not the
same lazy, one-row-at-a-time live OpenLibrary lookup that powers the
title-column preview, which for a page of thousands of rows would only ever
have resolved a handful by the time you looked at it. Crucially, this filter
is applied to the Tracked query before the 100-row pagination, not
after — the Tracked section can run to tens of thousands of rows and is
dominated by series-catalogue-only entries whose ScannedAt gets bumped
independently of any ISBN work, so a naive
after-paging filter would only ever see whatever's on the currently-loaded
page and could easily show zero results even when thousands of real matches
exist further back in the backlog. trackedTotal/pagination reflect the
filtered count when the filter is active.
Each row offers Preview,
Dismiss (mark reviewed), and Apply — which links the file to one of the
author's books. Within each section, rows are grouped by author, then by title.
Untracked groups by the guessed author (no folder exists yet); Tracked
groups by the author's current folder name on disk — the link is
folder-driven, so the folder name (not Author.Name, which can drift after a
rename until the folder itself is renamed) is the ground truth for which author
a tracked file is actually filed under right now.
Apply only ever touches a file that is not yet matched to a book. Once a
file is matched, its title is settled — that book (in the catalogue) is the
source of truth, not the front-matter guess — so the row shows the linked
book's real title (marked ✓ matched — title locked) with no Apply action,
and neither the per-row Apply nor Apply all can ever overwrite an
already-matched BookId. Such a row only remains on the list when it still
carries a series catalogue (for Build series).
When an unmatched row has an ISBN but no guessed/known title, the title column
resolves and shows what title that ISBN would end up using — the OpenLibrary
work its search.json?isbn= lookup returns, exactly what Apply would link to
(GET /api/identified/{id}/isbn-title, resolved lazily per such row so it never
blocks the list load). The result is cached in a dedicated IsbnResolutions
table keyed by the normalized ISBN — so it's resolved once per unique code and
shared by every file that carries it (a shelf of the same ISBN costs a single OL
call, not one per file), and the same ISBN is never re-queried against
OpenLibrary on later loads. Even a miss is remembered so a no-result ISBN isn't
retried. If that work's author disagrees with the file's folder author it's flagged
by «author» — Apply won't re-file across authors (the ISBN author-agreement guard
below refuses it), and a ↪ Reassign to «author» button is offered: it drives the
same use-work flow to resolve/create that author, move the file into their folder
and link the book — the explicit override for when the ISBN's author is the correct
one. A page-level Reassign all N to their ISBN author button (shown whenever
any loaded row has ISBN status "Needs reassigned to other author") does the same
for every such row across the whole backlog, not just what's on screen — driven
entirely from the cached IsbnResolutions table (no live OpenLibrary calls needed,
since a "reassign" row is by definition already resolved), in capped batches like
the other bulk actions (POST /api/identified/reassign-all-isbn). One row's
failure — a moved/deleted file, a blocked relink — is logged and skipped, not
fatal; it's left unreviewed for a later run while every other row in the batch
still gets processed.
The cache is warmed two ways: content-scan resolves each file's ISBN inline as it
scans (going forward, so newly-scanned files are pre-resolved), and a
resolve-isbns catch-up job (Schedules page, off by default) walks the distinct
ISBNs on existing scan rows and resolves any not yet cached — one OL call per unique
code, capped per run (Settings → Background job run limits → Cache ISBN title/author
lookups, default 200). Candidates are prioritized, not picked in table order or pure
random: never-attempted ISBNs go first — a fresh code gets its first try before a
slot is spent retrying one that's already failed — and once those run out, the rest of
the batch is filled from previously-failed ISBNs ordered by fewest failures so far
(random within each fail-count tier, so a huge backlog doesn't always surface the same
front-of-list candidates). An ISBN deferred because a source was quota-capped is also
skipped for the rest of the UTC day, so later runs spend their slots on fresh
candidates instead of re-grinding codes that can't resolve until the quota resets. The
job's "Done — resolved N, … remaining" summary stays visible on the Sync page after
the run finishes. Between them the Identified page never pays an on-demand
lookup for an ISBN that's already been seen. The ISBN itself is check-digit
validated before any lookup (ISBN-10 mod-11 / ISBN-13 mod-10), so a mis-extracted
number that merely happens to be 10/13 digits (an LCCN, ASIN, copyright-page code)
is rejected up front instead of burning a lookup and caching a junk miss.
Fallback chain (optional). A large share of ISBNs that resolve to nothing are
real codes for self-published / KDP / indie / foreign titles OpenLibrary simply
doesn't hold (confirmed against both OL's search.json?isbn= and /isbn/ endpoints).
For those, ISBN resolution falls back through a chain of secondary sources, in order,
until one resolves it (Settings → ISBN metadata fallbacks, each off unless its
credential is set):
- ISBNdb (
isbndb.com) — paid, the most comprehensive ISBN database. Checked first when configured, ahead of every free source — a definitive answer here means the free sources' quotas aren't spent chasing something ISBNdb already has. - Hardcover (
hardcover.app) — free community GraphQL DB; its indie/KDP-heavy readership often covers the tail better than OpenLibrary. - Library of Congress — free, no key (an on/off toggle); queried over SRU
(
lx2.loc.gov:210, MODS records). Strong on traditional and out-of-print US-published ISBNs — the bulk of the978tail the other sources miss. - Google Books — free, but capped at 1,000 lookups/day and shared with content-scan's inline resolution, making it the easiest of the three to exhaust. Checked last, so its quota is spent only on ISBNs Hardcover/LoC couldn't place.
A source with no credential is skipped instantly regardless of where it sits in the chain, so this ordering only matters once the source ahead of it is actually configured.
Whichever source hits first supplies the title (and author, when it has one), which is cached and shown on the Identified page. There's no OpenLibrary work behind a fallback result, so Apply-by-ISBN and the Reassign button stay inert for such a row (identification only — there's nothing to link to on OL). The trigger for running the chain at all is whether we actually have a title, not just "did OL return something" — OL's search can hand back a doc with a work key and even an author but no title, and that must still run the fallback chain the same as OL returning nothing. When there's no title yet, a title alone stops the chain — Apply never re-files a matched file across authors on its own, so the file keeps its existing/folder author regardless of what a fallback source says about who wrote it; there's no reason to keep spending calls on later sources chasing an author that will never be used. The chain keeps going through every source only in the other case: a title is already in hand (from OL) but there's no author (OL records are often authorless) — there it keeps OL's work key and title and specifically hunts for an author from a fallback, since a "we don't know the author" row is far less useful than a named one. When no source can supply the author, the title still shows and the cell reads "author not listed" — not the misleading "different author" (and no nameless Reassign is offered); a genuine mismatch is only claimed when the ISBN's author is actually known and differs. A source with no credential is skipped; with none configured the fallback is off and no external calls are made. Each source is rate-limited to respect its API, and a source that's temporarily rate/quota-capped returns "unavailable". Only when nothing at all came back — no hit, and not even a definitive "not found" from some other source — is the ISBN left uncached so it's retried later (see quota handling below). Crucially, one capped source does not block caching a miss that a different source already checked and confirmed: if Google's daily quota is spent but Hardcover or LoC definitively found nothing, that's cached as a real miss rather than deferred — otherwise the whole self-published tail stalls for the rest of the day the moment Google's quota runs out, even though other sources had already ruled it out.
Quota handling. Google Books enforces ~100 requests/min and 1,000/day. Calls
are paced under the per-minute cap, and when Google signals the daily quota is
spent (HTTP 429, or a 403 citing a quota/limit reason) the app latches Google off
for the rest of the day — further lookups short-circuit without an HTTP call, and
the ISBNs that couldn't be resolved are left uncached so they're re-attempted the
next day once the quota resets. The latch (GoogleBooksRateLimiter) is keyed to
the Pacific-time day, not UTC — Google Cloud API quotas (Books API included)
actually reset at midnight Pacific Time (~07:00 UTC in PDT / ~08:00 UTC in PST),
confirmed against Google's own 429 response body. An earlier version tracked UTC
days, which cleared the latch at 00:00 UTC while the quota itself stayed spent for
several more hours — a job or lookup running in that window saw "not exhausted",
spent a real call finding out otherwise, and re-latched; retry-isbn-misses running
early at 03:10 UTC hit this on its very first attempt every single day and could
never make progress. Fixed by tracking the Pacific day everywhere the latch is read
or set, and by scheduling retry-isbn-misses at 08:30 UTC (see above) so it runs
comfortably after the real reset in either DST state. ISBNdb deliberately gets no
such latch. Two attempts at detecting an ISBNdb "quota spent" state from its HTTP
status/body (first treating any 429 as the daily cap, then treating a 403 whose body
mentioned "quota"/"limit" as the cap) both ended up misfiring — the account was
demonstrably still working each time, but a single ambiguous error had disabled it
for the rest of the day. ISBNdb's real quota-exhausted response format isn't
reliably known, so guessing at it costs more (silently losing a whole day of a paid,
working source) than it saves. Every ISBNdb failure — 429, 403, or a network error —
is now just Unavailable for that one call, retried normally on the next lookup,
the same way Hardcover and LoC already behave. The resolve-isbns job presses on
past a quota-capped ISBN (counted as deferred, and skipped for the rest of the day
— see above) rather than stopping the batch; ISBNs resolvable via another source in
the same run are still cached (only the capped source is paused). On the Identified
page an on-demand lookup that hits the spent quota shows a ⏳ Google quota — retry
tomorrow note (the isbn-title endpoint returns a retryable state, not an error)
rather than a bare failure, and nothing is cached so the row resolves on a later load.
Because a "no result" is cached (so it isn't re-queried forever), ISBNs that were
looked up before you added a Google Books key stay null. Settings → Google Books
ISBN fallback has a Re-attempt failed ISBN lookups button
(POST /api/settings/reset-isbn-misses) that drops only the total-miss rows
(no work key and no title — the ones that resolved to nothing); rows that already
resolved via OpenLibrary or Google are untouched. The resolve-isbns job then
re-resolves the dropped ones on its next run, now with the Google fallback in play.
Give up after N failed attempts. An ISBN that comes back with nothing at all
(every reachable source unavailable, none even definitive) is deliberately left
uncached so it's retried later — but without a limit that's forever: a persistently
broken source, or a code no source will ever have, would be re-picked and
re-attempted on every future run. Each such failure is counted per-ISBN
(IsbnResolutionAttempt.FailCount); once it reaches Settings → ISBN metadata
fallbacks → "give up after N failed attempts" (default 5), the ISBN is instead
cached as a permanent miss — same outcome as a definitive miss, just reached by
exhaustion instead of confirmation. A later real answer (a source coming back online,
or a manual Re-attempt failed ISBN lookups) clears the counter, so it doesn't give
up again after a single attempt.
retry-isbn-misses job (Schedules page, off by default, 30 8 * * */08:30 UTC by
default — see Quota handling below for why that specific time) automates the above
without needing a manual reset + a later resolve-isbns
pass: it walks IsbnResolutions rows with no title directly (confirmed misses and
give-ups alike), oldest ResolvedAt first, deletes each stale row and re-resolves
it through the exact same chain (OpenLibrary, then the fallback sources in the order
above) — capped per run (Settings → Background job run limits → Retry failed ISBN
lookups, default 1000). It's gated on Google Books being configured (no key = the
job no-ops immediately — there's no point burning the other sources' calls on a pass
whose whole reason for existing is giving Google a shot at what it hasn't seen), and
stops the moment Google's daily quota latches exhausted, checked before every
single row rather than once up front, so a run that burns through the quota partway
still keeps whatever it resolved instead of grinding on pointlessly. OpenLibrary and
the other fallback sources are still tried per row same as always — Google is only the
run's stop signal, since it's the one source with a reliable daily-exhaustion latch
(ISBNdb deliberately has none, per above). Unlike resolve-isbns (which discovers
candidates by walking BookContentScan.Isbn), this job reads IsbnResolutions
directly, so it also catches ISBNs whose scan row no longer exists.
How the guess is resolved for an unmatched file is deliberately strict, because a title scraped from a book's text is only a guess, and we already know the author:
- ISBN (
?isbn=) is definitive for the book, but the file is filed under a folder author and Apply never re-parents it — so the ISBN's OpenLibrary work is taken only when its author agrees with the folder author (by OL author key when both have one, else by normalized name variants — the same matcher used for incoming files, soMcCaffrey, Annestill agrees withAnne McCaffrey). If the ISBN resolves to a work by a different author (a mis-scanned or wrong-edition ISBN), it is refused, not applied — otherwise a wrong-titled book would be minted under this author. The guess then falls back to the title path below, and if that finds nothing either the file is left for manual matching. This guard applies to the per-row Apply, Apply all guesses, and Apply all N ISBN matches alike. - Title (no ISBN) is matched against the author's OWN existing books — the
DB list of valid titles, kept comprehensive by the OpenLibrary author refresh —
using the same author-prefix strip + series-filename parsing + Jaro–Winkler fuzzy
(≥ 0.82) as the unmatched-file suggestions, with a trailing
Book N/Vol Ndescriptor stripped and the series position breaking ties between identically-prefixed titles (soHigh Druid of Shannara - Book 1links to that series' book 1). If nothing in the author's catalogue matches, the guess is refused, not applied — it does not invent an OpenLibrary work, which is what used to attach wrong books. (Match a genuinely-new book by hand instead.)
Because a file that lives in an author folder is already
attributed to that author (the author↔file link is folder-driven), Apply
keeps that author and never re-parents the file — it only attaches a book, it
never moves the file or invents a new author. That is exactly why the ISBN
author-agreement guard above matters: since a matched book is always filed under
the folder author, an ISBN pointing at another author's work is refused rather
than silently mis-filed. (The manual unmatched→OpenLibrary match, where you
explicitly pick a work, still resolves and re-parents to the work's author.) Nothing is applied automatically — Apply is a per-row, user-confirmed
action. (Apply works for tracked unmatched files.) For untracked
__unknown files an Assign to «author» button does the equivalent: it
works out whose book it is — resolving the guessed ISBN/title against
OpenLibrary to get a real author (matched or created by OL key) and link the
book, or, failing that, reusing an existing author whose name matches the guess.
A brand-new author is only created when the guessed name matches a real
OpenLibrary author (the local OpenLibrary authors catalogue) — in which case
the Pending author is tied to that OL key/name; an unverifiable name is refused
rather than invented, so guesses can't spawn junk authors. It then moves the
file into that author's folder and tracks it as one of their unmatched files,
so it shows in their Unmatched section and can be matched to a book next. This is
how untracked files get out of __unknown and onto an author. A header Assign
all N untracked books to authors button does this for every untracked row in
one click — in effect clicking each row's Assign to … button. The client loops
POST /api/identified/assign-authors-all?afterId=N with the returned cursor
(each request is capped because OpenLibrary is rate-limited) until the whole
backlog has been attempted once, showing running progress on the button; books
whose author can't be resolved are skipped and stay in the list; a row that carries a series catalogue is kept (now tagged with
its new author) so its series can then be built with the same Build series /
Build all series action as the tracked rows. The same bulk action also runs
as a scheduled job (assign-authors, default */15 * * * *, enabled by
default — capped per run (Settings → Background job run limits, default 1000)
so each firing stays within OpenLibrary's rate limit and the 15-minute schedule
works through the backlog). Rows it
couldn't resolve (an unverifiable author name, a vanished file) are durably
marked attempted (BookContentScan.AssignAttemptedAt) and skipped on every
later run, so the job stops re-querying OpenLibrary for the same wall of
unresolvable files every 15 minutes — and, unlike the previous in-memory skip
list, the marker survives a restart instead of replaying the whole backlog from
scratch. To re-evaluate everything (e.g. after adding authors that might now
match), clear the marker with Reset assign-attempt flags on the Settings page
(POST /api/settings/reset-assign-attempts). A header Apply all N ISBN
matches button bulk-applies every
ISBN-backed guess in one go (capped per call so it can't time out against
OpenLibrary's rate limit — repeat until none remain); title-only guesses are
left for per-row review.
Any file these actions move into an author folder (per-row Assign, the
bulk button, the assign-authors job, Accept author, or an OL match that
resolves to a different author) gets its integrity result reset so the next
check-integrity run re-checks it in its new home — a move keeps the file's
size/modified fingerprint, so without the reset the integrity job would never
look at it again. A file already flagged damaged keeps that verdict (it
stays on the Damaged page) — moving it didn't fix it; only the re-check can
clear it.
Each row's existence is also the "already scanned" marker, so a file is read only once (unless it changes). Damaged files (flagged by the integrity check) and files in the archive folder are skipped.
Two ways to run it:
- Scheduled / on-demand job (
content-scan, default0 21 * * *, disabled by default) — processes up to Max files identified per run (Settings page, default 50) each run, and shows live progress on the Sync page. - Per author — the Identify from content button on an author's page kicks off a background scan of that author's unmatched files (same per-run cap) and returns immediately; progress shows on the Sync page and results appear on the Identified Books page (the button links there). It runs in the background on purpose: reading files — especially Calibre-converted MOBI/AZW/LIT — can take minutes, far longer than a browser will hold a request open, so a synchronous call would abort. It's resilient too: a file it can't read is logged and skipped rather than failing the whole run.
Heuristics are best-effort and exact-match-free; review the guesses before hitting Apply. An ISBN-backed guess is the most reliable; a title-only guess can resolve to the wrong edition, so preview when unsure.
Export your library from Goodreads (My Books → Import/Export → Export Library) and upload the CSV on the Settings page.
The importer matches rows by normalized title + author against your tracked works, then:
- Sets ReadStatus = Read and ReadAt from the "Date Read" column for
rows on the
readshelf. - Sets ReadStatus = Reading for rows on the
currently-readingshelf. - Sets Wanted = true for rows on the
to-readshelf where the book is not yet owned.
The response shows matched / already-read / unmatched counts, plus a collapsible list of the first 50 unmatched titles.
Upload a plain-text inventory of physically-owned books on the Settings
page and the importer flips Book.ManuallyOwned = true on every row it can
match against your tracked library. Books already owned (in any sense — file
or physical) are counted but not updated. Empty-title rows are skipped.
The expected format is one book per line, either tab-separated
(Author<TAB>Title<TAB>Series+pos[<TAB>ISBN]) or fixed-width with the columns
at character offsets 0 (Author, 26 chars), 26 (Title, 44 chars), and 70+
(Series + position). An ISBN is captured when present — a dedicated 4th
tab column, or any ISBN-10/13-shaped token anywhere on the line:
Abbey, Lynn Sanctuary Thieves' World 4
Adams, Douglas The Hitchhiker's Guide to the Galaxy H2G2 1
…
Matching tries the most reliable key first:
- ISBN — an exact ISBN match is definitive; the row's title and author are ignored when it hits.
- Standard normalised title — same pipeline as the rest of the app
(
The Hobbit (1)→hobbit). - Loose key — replaces
&withand, normalises, then collapses all spaces. CatchesRock & RollvsRock and Roll, hyphen-vs-space differences, possessive apostrophes, and general punctuation noise.
A title hit (passes 2–3) only counts when the author also matches — two
different authors can share a title, so a title-only hit is left for manual
resolution rather than silently marking the wrong book owned. The match logic
lives in PhysicalMatchIndex, shared by the initial import and the rematch so
both apply identical rules.
Rows that match nothing are persisted to the PhysicalBookUnmatched table
(deduped by author+title, case-insensitive). The Unmatched physical page
(/physical-unmatched) is where you clear them:
- Edit any row's Author / Title / Series-position / ISBN inline.
- Delete rows you don't care about.
- Resolve a row through an inline panel: it auto-selects a likely tracked author (an exact name — including the "Surname, Forename" order — resolves with no typing), then either matches the row to one of that author's books (with fuzzy title suggestions) or adds it as a new book under that author. Only top-level authors and pen names are offered as targets — a non-pen-name child's books fold into its canonical.
- Re-run matching retries the whole list against the current library in one transaction — an exact (ISBN / title+author) hit or a high-confidence (≥ 0.9) fuzzy title match marks the book owned and clears the row.
- Preview matches is the same as a dry run: it lists every proposed (row → book) pairing with checkboxes so you review and apply only the ones you want.
This keeps the import workflow non-destructive: nothing is silently lost, and the unmatched list shrinks every time you fix, resolve, or add a row.
The library exposes an OPDS 1.2 Atom feed for reading apps (KOReader, Moon+ Reader, and other OPDS-capable clients).
| Feed | URL |
|---|---|
| Root navigation | /opds/catalog.xml |
| All authors | /opds/authors.xml |
| Author's works | /opds/authors/{id}.xml |
| Missing works | /opds/missing.xml |
| Recent releases | /opds/recent.xml |
Each entry links to the corresponding OpenLibrary page and includes cover thumbnails from the OpenLibrary covers CDN. The feed is navigation-only — file downloads are not served because the files live on a local filesystem path, not a web-accessible URL.
For plain feed readers (and automation like IFTTT/n8n), there's also an RSS 2.0 feed of new releases that any reader understands:
| Feed | URL |
|---|---|
| New releases (starred authors) | /rss/recent.xml |
| New releases (all tracked authors) | /rss/recent.xml?all=true |
Items cover works first published in the last 5 years (deduped per author/title, newest first, up to 200), each linking to its OpenLibrary page. The Recent Releases page has a 🔖 RSS link in its header.
Optional search inside the text of your matched ebooks, off by default (toggle under Settings → Full-text search). It's opt-in because indexing extracts and stores book text, which is heavy.
- A
BookTextIndextable holds the head of each indexed file's readable text (capped at ~200k chars), keyed by file path with aSourcetag. By default only matched books are indexed. Two opt-in Settings toggles widen the net: unmatched files in author folders (FullTextIndexUnmatchedAuthorFiles) and loose files in the__unknownquarantine (FullTextIndexUnknownFiles). Search results tag non-book hits as unmatched file /__unknownfile and show the filename. - Indexing reuses
BookTextReader(the same extractor the integrity check uses, converting MOBI/AZW/etc. to text as needed) and runs as a background job — the schedulableindex-fulltexttask, or a manual "Run indexing now" on the Search page. Each run indexes up toFullTextIndexMaxPerRunbooks (set in Settings, default 200), so a large library is worked through incrementally without ever blocking a request. It follows the same singleton-coordinator pattern as the other scheduled jobs (TryStart/IsRunning), and the Search page polls status while a run is in progress. - Extracted text is sanitised before storage (NUL bytes, C0 control chars and unpaired UTF-16 surrogates are stripped) and each book is saved independently, so a single malformed file can't fail the whole run.
- Eligibility is enforced in SQL for both the candidate batches and the
Search page's "Indexed X of Y" count, and the two use the same rules: only
ebook extensions (the
BookIntegrityChecker.IsEbookset), and never files under the archive folder (archived files are inert — the standingArchivePolicyrule). Rows that can never be indexed — folder-pointer rows,.rar/.pdb/.opfand other non-ebook files, archived copies — are excluded from both, so the indexed/eligible counter converges instead of showing a permanent remainder that every run re-attempts and skips ("indexing picks up nothing" while X < Y). - Search engine. On first index/search the service tries to stand up a SQL
Server full-text catalog + index over the text column; when that succeeds,
search uses fast
CONTAINS(per-word prefix match). When the Full-Text component isn't installed (the stock mssql Linux image doesn't include it) or permission is lacking, it falls back to a built-in inverted word index (TextIndexWord): indexing tokenises each file's text + title + author into distinct words (capped per file), and search does an indexed prefix seek per query word, AND-ing the results. Indexing processes starred authors first (byAuthor.Priority) within the matched and unmatched tiers. This never scans the off-rownvarchar(max)text, so it's fast on any SQL Server edition — noLIKE '%…%'table scan, no 504/timeout. The Search page shows which engine is active. (Switching engines or upgrading from an older build needs one Clear index → Run indexing to build the word index.) Each hit comes back with a ~160-char snippet.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/search?q=&source= |
Search indexed text (returns hits + snippets; empty when disabled). source = matched / unmatched / unknown to limit to one type, omitted = all. The Search page has a matching "in …" dropdown |
| GET | /api/search/status |
Enabled flag, indexed/eligible counts, per-run cap, running state + message, last-indexed time |
| POST | /api/search/run |
Start a background indexing run (one batch of up to FullTextIndexMaxPerRun books); returns immediately |
| POST | /api/search/clear |
Drop the whole index |
| GET/PUT | /api/settings/full-text-search |
Read / set the on-off toggle (default off), per-run batch size, and the two "also index unmatched / __unknown files" toggles. Saved with a Save button (consistent with the other settings) |
Optional, configured under Settings → Download automation with two keys: a
Newznab indexer (URL + API key) and a SABnzbd instance (URL + API key,
optional category). When both are set, each book on the Wanted page gets a
Grab button — it searches the indexer (t=search, book categories) for
"<author> <title>", takes the top result's NZB URL, and hands it to SABnzbd
via addurl. Keys are write-only in the UI (it shows whether one is set, never
the value; leaving a key field blank on save keeps the stored key). Endpoints:
GET/PUT /api/settings/download and POST /api/books/{id}/grab.
The Backup section on the Settings page downloads a single ZIP snapshot of the data you can't easily regenerate:
app_settings.json,library_locations.json,nzb_sites.json— configurationauthors.json— the full author watchlist (status, priority, OL keys, refresh cadence, notes, links/pen-names, notify flags)author_blacklist.json,ignored_folders.json— your exclusion rulesseries.json,series_authors.json— manual series structurebooks.json— every manual book (not on OpenLibrary, so unrecoverable) plus any book carrying user state (wanted / read / owned / suppressed), keyed by author OL key + work keyphysical_unmatched.json— physical-import rowsfile_manifest.json— (opt-in) every tracked local file path
Bulk catalogue data (OpenLibrary works, the OL author dump, disk-scan
LocalBookFile rows, the __unknown index) is deliberately excluded — a sync or
seed rebuilds it. GET /api/backup/export (add ?manifest=true for the file
list) returns the archive.
Restore (also in the Backup section) uploads an archive back via
POST /api/backup/import and merges it in by natural keys — authors by
OpenLibrary key (or name), series by normalized name, books by work key — so it
works even after a full rebuild where the original row IDs are gone. Existing
rows are updated in place and missing ones (manual books, books carrying user
state) are created; nothing is deleted. The whole import runs in one
execution-strategy transaction, so it's all-or-nothing.
Pair a reMarkable tablet from the Settings page to push EPUB / PDF files from any book's detail view straight to the device's cloud library.
- Log in at my.remarkable.com/device/desktop/connect and copy the 8-character one-time code.
- Paste it into reMarkable sync → Connect on the Settings page. The server exchanges it for a long-lived device token (stored in the DB) and caches a short-lived user token (JWT) alongside it, refreshing ~5 minutes before expiry.
- On any author page, each linked file gets a Send to reMarkable button.
EPUB and PDF upload as-is. Other formats (MOBI, AZW3, DOCX, FB2, CBZ, …)
get a Convert & send button that shells out to Calibre's
ebook-convertCLI to produce a temporary EPUB before uploading.
The endpoints talk to webapp.cloud.remarkable.com (auth) and
internal.cloud.remarkable.com (upload). Override in appsettings.json if
reMarkable moves them:
"Remarkable": {
"AuthHost": "https://webapp.cloud.remarkable.com",
"ApiHost": "https://internal.cloud.remarkable.com",
"DeviceDescription": "desktop-windows"
}Conversion uses the Calibre executable pointed at by Calibre:EbookConvert
— bare ebook-convert works if Calibre is on PATH, otherwise give the
absolute path. Leave the value empty to disable conversion entirely; only
native EPUB/PDF will be sendable.
"Calibre": {
"Root": "\\\\Server\\Books\\Calibre",
"EbookConvert": "ebook-convert"
}Running in Docker / on Azure? The default ASP.NET runtime image has no
ebook-convert, so Convert & send fails on every non-native file until you ship Calibre inside the container. See Calibre in the container for the two supported install paths (local Docker build vs.az acr buildplus<ContainerBaseImage>for the VS / .NET SDK publish flow).
Security — the device token grants full access to your reMarkable cloud library until you revoke it on the reMarkable website. It lives in the same database as your connection string; protect the database at rest the same way you would any credential store. Use Disconnect to clear it locally, and revoke on the reMarkable website to invalidate it everywhere.
Managed on the Schedules page (backed by Hangfire). Each job has a cron expression and an enabled/disabled flag, persisted to the database and applied on every startup.
| Job ID | Default cron | Purpose |
|---|---|---|
sync |
0 2 * * * |
Full sync — scan, author resolve, file matching |
seed |
0 3 * * * |
Seed local author catalog from OpenLibrary bulk dump |
author-updates |
0 4 * * * |
Apply OpenLibrary daily author-change log |
refresh-due-works |
every 10 min | Re-fetch works for authors with an overdue NextFetchAt |
incoming |
0 5 * * * |
Process the drop folder |
reprocess-unknown |
0 18 * * * |
Re-run matching on the __unknown bucket |
organize-series |
0 1,13 * * * |
Enforce flat-file layout, move files to series folders |
unzip |
0 0 * * * |
Extract .zip/.rar archives to incoming folder |
disambiguate-folders |
0 11 * * * |
Split shared-name author folders into per-OL-key folders; route files by title match |
same-name-authors |
0 */6 * * * |
Add OpenLibrary authors that share a name with one you already track — a pure DB lookup against the seeded OpenLibraryAuthor catalogue, no API calls |
star-physical-authors |
0 10 * * * |
Give 1 star to any author with at least one manually-owned physical book whose current star rating is 0 |
star-series-coauthors |
0 22 * * * (enabled by default) |
When a starred author writes for a series that also has volumes by other authors, give those co-authors 1 star (if currently unstarred) so a shared series is followed across all its authors. Plain set-based update; only ever raises a 0 → 1 (reversible by unstarring) |
cache-openlibrary-metadata |
30 10 * * * |
Backfill missing subjects and cache large OpenLibrary covers for existing books |
flatten-unknown |
0 9 * * * (disabled by default) |
Flatten the quarantine fully: move every file under each quarantine folder up to the __unknown root and remove the emptied folder tree, so the quarantine is loose files only. See the quarantine is flat |
dedupe-unknown |
30 9 * * * (disabled by default) |
Delete byte-identical duplicate files inside the quarantine (size-grouped, then SHA-256-verified), keeping the shortest-path copy of each. See Dedupe-unknown job |
dedupe-author-files |
0 4 * * * |
For every author with unmatched files, delete byte-identical duplicate copies within that author's own folder (one keeper), never comparing across different author folders. Same duplicate determination as dedupe-unknown; prefers keeping a matched book's copy. See Dedupe-author-files job |
promote-manual-books |
30 7 * * * |
Two parts. Books: search OpenLibrary for each manually-catalogued book and link it to the real work once OL lists it — in place, or merged into the author's existing row for that work. Authors: search OpenLibrary for each hand-added author (synthetic XX…A key) and, on an exact normalized-name match, swap in the real OL key and mark them due so the next refresh fully populates them. See Promote-manual-books job |
check-integrity |
0 12 * * * (disabled by default) |
Open/convert every matched ebook file and flag any that won't open or have fewer than 20 pages onto the Damaged page. See Book integrity check |
prune-stale-files |
0 20 * * * |
Remove leftover folder-pointer LocalBookFile rows (empty/missing title folders) so they stop showing as bogus duplicates, AND delete every recursively-empty directory left behind under each mounted root. NAS-guarded: only folder-shaped rows, only directories with no files beneath them, only when the library root is mounted; library/quarantine/archive/incoming roots are never removed |
content-scan |
0 21 * * * (disabled by default) |
Read the front matter of unmatched/untracked files to guess author/title/series; results land on the Identified Books page. See Identifying books from content |
assign-authors |
*/15 * * * * |
File identified untracked books under their author, creating Pending authors from OpenLibrary where needed — the Identified page's "Assign all untracked books to authors" bulk action on a schedule. Capped at 100 rows per run (OpenLibrary rate limits); skips a firing when the Hangfire queue is already backed up |
index-fulltext |
0 * * * * (disabled by default) |
Extract and index ebook text for full-text search. No-op unless the feature is enabled in Settings; indexes up to FullTextIndexMaxPerRun books per run. See Full-text search |
prune-authors |
40 3 * * * (disabled by default) |
Delete empty auto-created authors — rows whose CreationSource is same-name/assign/content-scan/adopt, status Pending/NotFound, priority 0, with no books, no local files, no links and no notes. Never touches manual/restored/pre-existing authors. Capped 5000/run. Destructive, so opt-in |
duplicate-auto-archive |
30 3 * * * (disabled by default) |
For every book with more than one live copy, keep the best one (healthy beats damaged, then preferred format, then lowest id — same rule as the Duplicates page) and archive the rest — the automated "Archive extras". Moves files (forward-slash paths, source verified removed via SafeMove), so it ships disabled; opt in on the Schedules page |
verify-archive-duplicates |
50 3 * * * (disabled by default) |
A more cautious sibling of duplicate-auto-archive: before archiving anything, extracts and compares each copy's text content (front + back matter, via the same BookTextReader full-text search uses) against the keeper's, using word-overlap similarity (robust to reflow/OCR noise between formats). Only archives the extras when every copy matches within the configured threshold (Settings → Duplicate content match threshold, default 90%); if even one copy can't be confirmed similar enough — including when text extraction fails or yields too little text to judge — the whole book is left completely untouched for manual review, never partially archived. Every book it checks gets an Activity line with the actual percentage(s) computed (unreadable.epub: n/a, copy2.pdf: 62.4%, ...) and whether that led to an archive or a hands-off skip — not just the ones it archived — so you can see how well the matcher is actually doing and tune the threshold accordingly. Capped 100 duplicate groups/run (VerifyArchiveDuplicatesMaxPerRun); heavier than duplicate-auto-archive (opens/converts every file) and moves files, so it ships disabled — opt in on the Schedules page |
series-watch |
0 14 * * * (disabled by default) |
When a series you own a book in gains a recently-added (≤14 days) volume you don't own, mark it Wanted and send one Pushover summary — the high-signal "next in a series I'm collecting" case. Acts on your collection, so it ships disabled; opt in on the Schedules page |
auto-replace-damaged |
0 15 * * * (disabled by default) |
Search the indexer and send the best replacement to SABnzbd for each damaged book (the automated "Grab"). Capped at 20/run; no-ops when Download automation isn't configured. Pulls downloads, so it ships disabled; opt in on the Schedules page. The Damaged page also has a per-book ⤓ Grab replacement button |
resolve-works |
0 16 * * * (disabled by default) |
Link files that already know their author but not their work, using the ISBN we already extracted: ISBN → OpenLibrary work (the /isbn/{isbn}.json edition endpoint first — it resolves ~2× the ISBNs the search index does — then the ISBN search) → ensure the Book under the file's existing author → set BookId. A lenient title check rejects a mis-extracted ISBN. Closes the gap where the title-only matcher never consulted ISBN and the ISBN-aware assigner skipped author-linked files. DB-only candidate selection (no NAS reads), capped 200/run; makes OL calls + creates Book rows, so it ships disabled — opt in on the Schedules page or trigger once to work the backlog |
resolve-isbns |
30 16 * * * (disabled by default) |
Warm the shared IsbnResolutions cache for ISBNs that predate content-scan resolving them inline. Walks the distinct ISBNs on BookContentScan rows, resolves any not yet cached against OpenLibrary (search.json?isbn=, one call per unique code — shared by every file that carries it), and stores title/author/work-key so the Identified page never looks them up on demand. Never-attempted ISBNs are prioritized first, then previously-failed ones by fewest failures so far; one that keeps failing past the configured limit is given up on (see Identifying books from content). DB-only candidate selection, capped 200/run (ResolveIsbnsMaxPerRun); makes OL calls, so it ships disabled — new scans populate the cache without it, this just backfills the pre-existing backlog |
retry-isbn-misses |
30 8 * * * (disabled by default) |
Reprocess IsbnResolutions rows with no title (confirmed misses / give-ups), oldest ResolvedAt first — deletes the stale row and re-resolves through the same chain (OpenLibrary, then Google/Hardcover/LoC/ISBNdb). Gated on a Google Books API key being configured (no-ops immediately otherwise) and stops the instant Google's daily quota latches exhausted — checked before every row, not just once. 08:30 UTC is deliberately after Google's real reset (midnight Pacific time, ~07:00-08:00 UTC depending on DST — not UTC midnight, see Quota handling above), so it actually has fresh quota to spend instead of immediately rediscovering yesterday's exhaustion. OpenLibrary and the other sources are still tried per row as usual; Google is only the run's stop signal (the one source with a reliable daily-exhaustion latch). Capped 1000/run (RetryIsbnMissesMaxPerRun); ships disabled — opt in on the Schedules page or trigger once to work the backlog |
review-unapplicable-scans |
30 6 * * * (enabled by default) |
Marks a tracked, unmatched BookContentScan row reviewed (removing it from the Identified page) once it's confirmed neither its ISBN nor its title can resolve to any of its author's known books — clearing backlog clutter that Apply could never clear on its own. ISBN takes priority, using only the cached IsbnResolutions lookup (no live OpenLibrary call): a code with no cached resolution yet is left alone for resolve-isbns/retry-isbn-misses to finish first, and one that resolves to an author-matching work is left for apply-all to actually link. The title fallback calls the exact same matcher apply-content-guess uses (SyncService.FindBestKnownBookAsync) rather than a separate copy, so this job and Apply can never silently disagree on what counts as a match. Capped 500/run (ReviewUnapplicableScansMaxPerRun) |
llm-identify |
0 17 * * * (disabled by default) |
Last-resort, paid identification of opaque __unknown files (no author the deterministic + filename paths could find). Sends the signals we already hold — filename, embedded metadata, ISBN, a front-matter snippet — to the configured LLM (Claude or ChatGPT, set in Settings → AI identification), then feeds the guessed title/author through the same OpenLibrary validation + assignment as everything else, so a hallucination is rejected, never filed. No-ops unless enabled with an API key. Cost is bounded by a per-run cap and a hard rolling daily cap, and each file is marked LlmAttemptedAt so a hopeless file is never re-sent. When the LLM finds a title/author/ISBN but OpenLibrary can't confirm it, the guess is kept on the scan row and shown on the Identified page for manual review (not discarded). Settings → AI identification has a Re-attempt untracked files button (POST /api/settings/reset-llm-attempts) that clears the LlmAttemptedAt marker so the job re-tries them (e.g. after raising the cap or switching model). Ships disabled |
llm-title-match |
30 17 * * * (disabled by default) |
For unmatched files under starred authors, sends the same signals to the configured LLM, but instead of an OpenLibrary lookup it matches the guessed title against that author's existing book catalogue by normalized title — so it only ever links to a book already known locally, never invents one. Shares the same paid per-run/daily budget as llm-identify (one pooled usage counter), and marks each file LlmTitleMatchAttemptedAt so a hopeless file is never re-sent. Ships disabled |
mark-other-editions |
0 19 * * * (enabled by default) |
Where the same author has several catalogue entries for the same title (NormalizedTitle) and at least one of them has an ebook file linked, mark every fileless sibling Owned (other edition) (OwnedDifferentEdition) — you own the work, just a different edition than that row — so the duplicate entries drop off the Missing / Wanted lists. "Has an ebook" is LocalFiles.Any(), the same predicate used for ownership everywhere else, so the job is a single set-based UPDATE with no NAS reads. Idempotent (rows already flagged are skipped) and reversible (untick Other edition on a book to undo) |
mark-editions-read |
0 18 * * * (enabled by default) |
Where the same author has several catalogue entries for the same title (NormalizedTitle) and at least one is marked Read (ReadStatus.Read), mark every other edition Read too — reading one edition means you've read the work, whichever row carried the file. Editions already Read keep their existing ReadAt; the rest get the run time. A single set-based UPDATE with no NAS reads; idempotent |
Hangfire runs with WorkerCount=1, and all background work also passes through
a single BackgroundTaskCoordinator, so a manual UI run and a cron tick can't
clash. A firing that can't immediately take the coordinator waits for it — but
because that wait holds the single worker, the ceiling is short for cron
firings (2 minutes) and only longer for explicit manual triggers (10 minutes).
This avoids the starvation footgun where one long-running or stuck coordinator
holder let a single waiting job pin the only worker for up to two hours and
silently freeze every other scheduled job; a cron firing now gives up quickly,
frees the worker, and retries on its next tick. The dashboard is exposed at
/hangfire.
The same-name author folder disambiguator also supports a dry run. Calling
POST /api/authors/disambiguate-folders?dryRun=true returns the proposed file
moves, target folders, rename count, and orphan-fallback cases without touching
the disk or database. The normal POST /api/authors/disambiguate-folders
endpoint still performs the real move/rename pass.
- .NET SDK 10.0 or later —
winget install Microsoft.DotNet.SDK.10on Windows,brew install --cask dotnet-sdkon macOS, or grab the installer from dot.net/download. Check withdotnet --version. - Node.js 20.x or later — for the Vite dev server.
nodejs.org. Check with
node --version. - A SQL Server instance — any edition reachable over TCP/IP works. The next section walks through the easy free options.
- A folder for ebooks — not required to be a structured library, or to exist
yet. An existing
<Root>/<Author>/<Title>library works; so does any plain folder tree, or an empty folder you populate later through the incoming pipeline. You can even skip library locations entirely and use the app purely to track authors, works, and a wishlist. - (optional) Calibre's
ebook-convertCLI — only needed if you want to push non-EPUB/PDF formats to reMarkable. See reMarkable sync. - (optional) Docker — useful for the one-shot SQL Server container recipe below.
The Library needs SQL Server 2019 or newer (any edition). All three options below are free for personal use; pick whichever fits your platform.
A single container, runs the same on Windows / macOS / Linux. The image is licensed as Developer Edition, which is free for non-production use.
docker run -d --name thelibrary-sql \
-e "ACCEPT_EULA=Y" \
-e "MSSQL_SA_PASSWORD=YourStrong!Passw0rd" \
-p 1433:1433 \
--restart unless-stopped \
-v thelibrary-sql-data:/var/opt/mssql \
mcr.microsoft.com/mssql/server:2022-latest-
The named volume
thelibrary-sql-datakeeps your data acrossdocker rm. -
The SA password must include upper/lower/digit/symbol or the container exits on boot. Check with
docker logs thelibrary-sqlif it dies. -
Apple Silicon (M1/M2/M3) Macs: replace
mssql/server:2022-latestwithazure-sql-edge— it's an arm64-native cut-down build that supports the same features The Library uses:docker run -d --name thelibrary-sql \ -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=YourStrong!Passw0rd" \ -p 1433:1433 mcr.microsoft.com/azure-sql-edge
The connection string for this setup:
Server=localhost;Database=TheLibrary;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;Max Pool Size=100;
A free local install, persists outside Docker, no daemon to remember.
- Download SQL Server 2022 Express from microsoft.com/sql-server/sql-server-downloads.
- Run the installer, pick the Basic install type. It creates an instance
called
SQLEXPRESSonlocalhost. - Express tops out at 10 GB per database and 1 GB RAM — fine for a personal library (a watchlist with 10 000 books uses ~50 MB).
- Optional but recommended: install SQL Server Management Studio (SSMS) for a GUI to inspect the DB.
You can connect via Windows Authentication (no password needed):
Server=localhost\SQLEXPRESS;Database=TheLibrary;Trusted_Connection=True;TrustServerCertificate=True;
Same install as Express but full-featured (no DB-size cap, no RAM limit) and free for non-production use. Same downloads page; pick "Developer" instead of "Express". Use the same connection-string form as Option B.
If you used the SA account for first-time setup, create a non-admin login once the container/instance is up. Pick whichever client you have:
-- Inside SSMS, Azure Data Studio, or `sqlcmd -S localhost -U sa -P YourStrong!Passw0rd`
CREATE LOGIN TheLibrary WITH PASSWORD = 'AnotherStrong!Passw0rd';
CREATE DATABASE TheLibrary;
GO
USE TheLibrary;
CREATE USER TheLibrary FOR LOGIN TheLibrary;
ALTER ROLE db_owner ADD MEMBER TheLibrary;
GOConnection string for the new login:
Server=localhost;Database=TheLibrary;User Id=TheLibrary;Password=AnotherStrong!Passw0rd;TrustServerCertificate=True;Max Pool Size=100;
The Library auto-creates and migrates its schema on first start — you don't need to run any
.sqlscripts manually. TheCREATE DATABASEstatement above is optional too; if you skip it, the app's user just needsdbcreatorserver role and EF Core will create the DB on first boot.
git clone <your-fork-or-origin-url> TheLibrary
cd TheLibrary
# Server packages
cd TheLIbrary.Server
dotnet restore
# Client packages
cd ../thelibrary.client
npm installUse .NET user secrets
so the password stays out of git. Run from TheLIbrary.Server/:
dotnet user-secrets set "ConnectionStrings:Library" \
"Server=localhost;Database=TheLibrary;User Id=TheLibrary;Password=YourStrong!Passw0rd;TrustServerCertificate=True;Max Pool Size=100;"User secrets are stored under %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json
(Windows) or ~/.microsoft/usersecrets/<id>/secrets.json (Linux/macOS), outside
the repo.
Alternative for production deployments — set the environment variable
ConnectionStrings__Library (double-underscore, escapes the :) and skip the
user-secret command.
The app's OpenLibrary identity (a User-Agent app name + contact email) is
not a secret-file setting — you set it on the Settings page after first
launch, and it's stored in the database. It's optional (the app runs fine
anonymously at 1 req/sec) but recommended; see
Identifying the application.
Library locations and the incoming drop folder are stored in the database and
managed from the Settings page after first launch. For first-run
convenience, the server seeds one location from Calibre:Root in
appsettings.json if the locations table is empty:
"Calibre": { "Root": "D:\\Books\\Calibre" }This is only consulted once — after the first sync, all location management happens through the UI. You can leave the value as the default and add locations via Settings after the app starts.
cd TheLIbrary.Server
dotnet runExpected first-boot output:
- EF Core applies all pending migrations against the empty database.
- Hangfire registers its recurring jobs (all disabled by default except
organize-series,unzip,disambiguate-folders, andsame-name-authors). - Kestrel reports the listening URL (typically
https://localhost:5043).
The Vite dev server starts automatically and proxies /api and /hangfire
to the backend, so opening the Vite URL gives you both the app and the
Hangfire dashboard at /hangfire.
Both servers in one command is the default — the
Microsoft.AspNetCore.SpaProxyreference in the .csproj wires it up. If you'd rather run them separately (e.g. to debug only the server), build withdotnet build /p:SkipSpa=truethencd ../thelibrary.client && npm run devin a second terminal.
- Open the app — you'll land on the empty Authors page.
- Go to Settings and:
- Add at least one library location pointing at your ebook folder tree.
- Set the incoming folder path (where new files arrive for processing).
- Add your first author:
- Click + Add author from the Authors page.
- Search by name; pick the OpenLibrary candidate.
- (Optional but recommended for large catalogs) go to Sync and run Seed authors from OpenLibrary dump. It downloads the ~2 GB author dump so subsequent Add author searches are instant and offline.
- From Sync, click Start sync. The first run resolves OL keys, fetches works for each tracked author, and walks your library folders. Expect ~1 second per author plus ~1 second per 100-work page.
After the first sync, all background work happens on the schedule you set in the Schedules page (most jobs are disabled by default so nothing runs unexpectedly until you turn it on).
| Symptom | Likely cause | Fix |
|---|---|---|
Missing ConnectionStrings:Library on startup |
User secret never set, or set in the wrong working directory | Run dotnet user-secrets list from TheLIbrary.Server/. If empty, repeat step 2. |
A network-related or instance-specific error … (provider: TCP Provider) |
SQL Server unreachable on the host/port in your connection string | Verify the container/service is running: docker ps or services.msc. Confirm the port with `netstat -ano |
Login failed for user 'TheLibrary' |
Login exists at the server but lacks DB access, OR password mismatch | Re-run the CREATE USER … ALTER ROLE db_owner block from the "create a dedicated login" section. |
The certificate chain was issued by an authority that is not trusted |
Connection string is missing TrustServerCertificate=True |
Add it; the default SQL Server cert is self-signed and the client refuses by default. |
Cannot open database "TheLibrary" |
DB doesn't exist and the login lacks dbcreator |
Either run CREATE DATABASE TheLibrary; first, or grant dbcreator to the login. |
| Migrations error: "The model has pending changes…" | Local source tree has model changes ahead of the latest migration | Pull the latest tree, or cd TheLIbrary.Server && dotnet ef migrations add MyChange --output-dir Data/Migrations. See Adding a schema change. |
Vite proxy errors like ECONNREFUSED in the browser |
Server hasn't finished migration yet, or crashed | Watch the dotnet run terminal for the actual error; the SPA proxy waits for the server. |
| Apple Silicon Mac, container exits on boot | mssql/server:2022-latest is amd64-only |
Use mcr.microsoft.com/azure-sql-edge instead. |
- Genres & bios populate automatically on the first sync after the author is added. Books predating the genre feature are backfilled on the next sync; books for which OL has no subjects are marked with an empty string so they aren't re-checked on future syncs.
- Refresh cadence is per-author and self-balancing — see Works refresh cadence. Recently-active authors are checked every 2 days; long-dormant ones only every 60 days.
- Drop-folder ingestion: drop EPUB / MOBI / PDF / etc files into the
configured incoming folder, then click Process incoming on the Sync
page (or wait for the
incomingschedule). Files matched to a tracked author land under<primary library>/<Author>/. Unmatched files sit in__unknownuntil you either add the author or use the Untracked page's "Try matching all" / "Suggest from OL". - Backups: the SQL Server database is the only mutable state; the file
tree is read-only-ish (organiser moves files but never deletes book content).
Back up the database with
BACKUP DATABASE TheLibrary TO DISK = …or by copying the docker volumethelibrary-sql-data.
The server runs correctly inside a Docker container with the library share
mounted at a container-local path (e.g. /Books/Collection). The smallest
working setup is two containers: the SQL Server image from
Option A above
plus an image of the app, both sharing a Docker network so the connection
string can use the SQL container's name as the host.
A minimal app Dockerfile (publish first, then copy):
dotnet publish TheLIbrary.Server -c Release -o ./publish
docker build -t thelibrary:latest -f - . <<EOF
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY publish/. .
ENV ConnectionStrings__Library="Server=thelibrary-sql;Database=TheLibrary;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;Max Pool Size=100;"
ENV ASPNETCORE_HTTP_PORTS=5043
EXPOSE 5043
ENTRYPOINT ["dotnet", "TheLIbrary.Server.dll"]
EOFOr use dotnet publish /t:PublishContainer which builds the image directly
(the .csproj already has <ContainerRepository> and <ContainerPort> set).
Then:
docker network create thelibrary
docker network connect thelibrary thelibrary-sql
docker run -d --name thelibrary \
--network thelibrary \
-p 5043:5043 \
-v /path/on/host/books:/Books/Collection \
thelibrary:latestIf LocalBookFile records were previously written with Windows UNC paths
(\\server\share\Books\Collection\…), the series organizer and unzip job
automatically strip the \\server\share prefix to recover the container-local
path for all file I/O and update the DB records to the container path format
as they process each file. So a Windows-host install can move to a Docker
deployment without re-syncing.
The reMarkable upload path is EPUB- or PDF-only — anything else (MOBI, AZW3,
FB2, DOCX, CBZ, TXT, …) is converted in-process by shelling out to Calibre's
ebook-convert CLI. The bare mcr.microsoft.com/dotnet/aspnet:10.0 runtime
image does not include Calibre, so a publish that uses the default base
image will fail every Convert & send with:
Could not run ebook-convert (No such file or directory). Install Calibre and set Calibre:EbookConvert in appsettings.json…
Two ways to get Calibre into the image, depending on how you publish.
Path A — docker build from the repo's Dockerfile. The included
Dockerfile installs calibre (plus a small Qt/EGL runtime dep set) in the
runtime stage by default. No extra steps. To skip Calibre and ship a smaller
image (e.g. for a wishlist-only deployment that never sends to reMarkable),
pass --build-arg INSTALL_CALIBRE=false.
Path B — dotnet publish /t:PublishContainer or Visual Studio "Publish
to Container Registry". This route uses the .NET SDK's container builder
(Microsoft.NET.Build.Containers), which can only pull a base image from
a registry — it cannot run apt-get while building. The Dockerfile above is
not consulted at all. To get Calibre into the image you have to build a
Calibre-enabled base image once and point <ContainerBaseImage> at it.
A Dockerfile.aspnet-calibre recipe is committed in the repo root for this
purpose. Two ways to build and push it:
-
No Docker daemon locally —
az acr build(recommended for VS users). Builds in Azure Container Registry, no Docker Desktop / buildx install needed. Because the .NET SDK build doesn't read your repo for this step, do it from a one-file directory soaz acr builddoesn't try to pack your whole repo (which fails on.vs\…\*.vsidxfiles locked while VS is open):mkdir "%TEMP%\aspnet-calibre-build" copy Dockerfile.aspnet-calibre "%TEMP%\aspnet-calibre-build\Dockerfile" cd /D "%TEMP%\aspnet-calibre-build" az acr build --registry <YourRegistryName> --image aspnet-calibre:10.0 --file Dockerfile . cd /D <repo-root>
The
cdinto the temp dir matters — without it, the CLI resolves--file Dockerfileagainst the current working directory and packages your repo's main multi-stage Dockerfile instead, which then fails onthelibrary.client/package.jsonnot existing in the tiny context. -
Local Docker daemon present. From the repo root:
docker build -f Dockerfile.aspnet-calibre -t <YourRegistry>.azurecr.io/aspnet-calibre:10.0 . az acr login --name <YourRegistryName> docker push <YourRegistry>.azurecr.io/aspnet-calibre:10.0
Either way, the resulting image is your runtime base. Add this line inside
TheLIbrary.Server.csproj (in the existing <PropertyGroup>, after
<ContainerPort>):
<ContainerBaseImage>YOUR-REGISTRY.azurecr.io/aspnet-calibre:10.0</ContainerBaseImage>…then publish from VS as normal. The SDK container build layers your app
on top of the Calibre-enabled base, ebook-convert is on PATH, and
Convert & send works. Rebuild the base image whenever you want to pick
up upstream ASP.NET security patches (every few months is fine for a
personal deployment).
App Service / Container Apps registry permissions. If Azure complains
about pulling the base image during a publish or cold start, grant your
hosting identity the AcrPull role on the registry holding
aspnet-calibre. That's usually already in place when the app image
itself lives in the same registry.
Every /api/* route always responds with JSON, including on error: a global
MVC exception filter (ApiExceptionFilter) turns any unhandled controller
exception into a 500 { "error": … }, and unmatched /api paths 404 as JSON
rather than falling through to the SPA's index.html. This is what keeps the
client from ever trying to JSON.parse an HTML error page (the
"Unexpected token '<'" class of bug).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/authors |
List tracked authors |
| POST | /api/authors |
Add an author to the watchlist from an OL key |
| POST | /api/authors/bulk-status |
Bulk set status (Active / Pending / Excluded) for a list of authors |
| POST | /api/authors/{id}/merge |
Merge a source author into a target — reassigns all books and local files, then deletes the source |
| POST | /api/authors/{id}/books |
Catalogue a manually-added book under this author (synthetic XX work key) |
| GET | /api/authors/{id} |
Author detail + books (with genres, series, read status) + unmatched local files + associated series (primary and secondary) |
| PUT | /api/authors/{id}/priority |
Set 0–5 star priority |
| PUT | /api/authors/{id}/refresh-interval |
Set or clear a fixed works-refresh interval (days) |
| PUT | /api/authors/{id}/notify-new-books |
Toggle Pushover new-book alerts for this author (requires credentials configured) |
| POST | /api/authors/{id}/refresh |
On-demand single-author OpenLibrary refresh |
| DELETE | /api/authors/{id} |
Remove an author (moves files back to incoming) |
| GET | /api/authors/starred |
Authors with priority ≥ 1 |
| POST | /api/authors/{id}/unmatched/{fileId}/match |
Force-match an unmatched local file to a work (accepts books owned by linked non-pen-name children) |
| DELETE | /api/authors/{id}/unmatched/{fileId}/match |
Undo a match |
| POST | /api/authors/{id}/unmatched/{fileId}/return-to-incoming |
Move the file's folder back to incoming |
| GET | /api/authors/{id}/unmatched/suggestions |
Returns top-N fuzzy-scored book candidates per unmatched file (default top=3) |
| POST | /api/authors/{id}/unmatched/bulk-match |
Apply a batch of (fileId, bookId) pairs in one call (used by the "Confirm" button) |
| POST | /api/authors/{id}/unmatched/{fileId}/openlibrary-match |
Match one unmatched local file to an OpenLibrary work; may auto-create/select a different target author and relocate the file on disk |
| POST | /api/authors/{id}/unmatched/openlibrary-bulk-match |
Batch filename-based OpenLibrary matching for unmatched local files |
| PUT | /api/authors/{id}/unmatched/{fileId}/additional-books |
Attach extra book ids to a file representing multiple works (omnibus / boxed set) |
| PUT | /api/authors/{id}/link |
Link this author to a canonical (body: { canonicalAuthorId, isPenName }). Duplicates physically move files; pen names don't. |
| DELETE | /api/authors/{id}/link |
Remove the link (does not move files back) |
| POST | /api/authors/disambiguate-folders |
Run the same-name author folder disambiguator now; ?dryRun=true returns a preview only |
| GET | /api/authors/disambiguate-folders/status |
Polling endpoint for the running state + last summary |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/books/{id}/ownership |
Manually mark a book owned/not-owned |
| POST | /api/books/bulk-ownership |
Bulk mark a list of books owned/not-owned |
| PUT | /api/books/{id}/read-status |
Set ReadStatus (Unread/Reading/Read/Dnf) and optional ReadAt date |
| PUT | /api/books/{id}/wanted |
Toggle the Wanted flag |
| POST | /api/books/bulk-wanted |
Bulk set the Wanted flag for a list of books |
| PUT | /api/books/{id}/suppressed |
Hide a book from the main author-detail list (rendered in a collapsed section at the bottom; reversible) |
| GET | /api/books/missing |
Unowned books from starred authors (includes Wanted, Subjects, Series) |
| GET | /api/books/missing/export |
CSV download of the full missing-works list |
| GET | /api/books/{id}/file-candidates |
Up to 20 fuzzy-scored file candidates for a missing book — author's unmatched LocalBookFile records first, then files in the __unknown quarantine folder(s) |
| POST | /api/books/{id}/link-file |
Link a candidate file to a book (by FileId or raw FilePath); optionally move the file into the author's library folder (Move: true) |
| GET | /api/books/recent-releases |
Works published in the last 5 years (starred authors) |
| GET | /api/books/recent-releases/all |
Works published in the last 5 years (all authors) |
| GET | /api/books/duplicates |
Books matched to more than one local file folder |
| POST | /api/books/duplicates/actions |
Archive or delete selected duplicate files from disk |
| GET | /api/books/genres |
All distinct subject tags sorted by frequency |
| GET | /api/books/series |
All series with book lists, owned counts, and primary author |
| PUT | /api/books/{id}/series |
Set or clear a book's series name and position |
| PUT | /api/books/{id} |
Edit a book's title, publish year, and/or author |
| DELETE | /api/books/{id} |
Delete a book (its local files fall back to unmatched) |
| GET | /api/books/manual |
List every manually-added book |
| POST | /api/identified/{id}/use-work |
Match a scan row's file to a user-picked OpenLibrary work, fully applied: author resolved/created Pending, Book ensured, file moved into the author folder and linked, row retired |
| POST | /api/jobs/promote-manual-books/start |
Manually start the promote-manual-books job |
| PUT | /api/books/{id}/cover |
Set or clear a custom cover image URL |
| GET | /api/books/cover-search?q= |
Cover-image candidates from Google Books |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/series |
Lightweight series list for dropdowns (id, name, primary author, parent) |
| POST | /api/series |
Create a new series (name, optional primary author, optional parent + position) |
| GET | /api/series/{id} |
Series detail including additional authors, parent, and child series |
| PUT | /api/series/{id} |
Update series name, primary author, additional authors, parent series, and position in parent |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/stats |
Library KPIs, read-by-year, top genres, author coverage, file-format breakdown, files-acquired-by-month |
| GET | /api/dashboard |
Count-only summary for the Home dashboard cards (wanted, damaged, untracked folders, unknown files, authors due refresh, releases this year, added this week, owned/missing/active) — all COUNT queries so it stays fast on a large library |
| POST | /api/import/goodreads |
Import a Goodreads export CSV (multipart/form-data file) |
| POST | /api/import/physical-books |
Import a physical-books inventory (tab or fixed-width); marks matches as ManuallyOwned and persists unmatched rows for later editing |
| GET | /api/import/physical-books/unmatched |
List rows from past imports that couldn't be matched |
| PUT | /api/import/physical-books/unmatched/{id} |
Edit an unmatched row's Author / Title / SeriesPos / ISBN |
| DELETE | /api/import/physical-books/unmatched/{id} |
Remove an unmatched row |
| GET | /api/import/physical-books/unmatched/author-suggestions |
Likely tracked author per unmatched row |
| GET | /api/import/physical-books/unmatched/{id}/book-suggestions?authorId= |
Fuzzy-scored book candidates for a chosen author |
| POST | /api/import/physical-books/unmatched/{id}/match |
Resolve a row by tying it to an existing book |
| POST | /api/import/physical-books/unmatched/{id}/add-book |
Resolve a row by creating a new book under an author |
| POST | /api/import/physical-books/unmatched/rematch |
Re-run matching; exact or ≥0.9 fuzzy hits mark the book owned and clear the row |
| POST | /api/import/physical-books/unmatched/rematch/preview |
Dry run of rematch — proposed matches without applying |
| POST | /api/import/physical-books/unmatched/bulk-resolve |
Apply a reviewed set of (row → book) matches in one transaction |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/unclaimed |
Library folders with no matching tracked author |
| DELETE | /api/unclaimed?folder= |
Move a folder back to incoming and blacklist the name |
| DELETE | /api/unclaimed/all |
Move all unclaimed folders back to incoming |
| GET | /api/unknown-folders |
Author-level folders AND loose book files inside __unknown (or the custom quarantine path when set); loose files carry isFile: true |
| POST | /api/unknown-folders/match |
Try matching every __unknown folder against the current watchlist (incl. OL alternate names) and move matches into the canonical author folder |
| DELETE | /api/unknown-folders?folder= |
Move one __unknown folder (or loose root file) back to incoming |
| DELETE | /api/unknown-folders/all |
Move all __unknown folders and loose root files back to incoming |
| GET | /api/untracked/contents |
List files and subfolders inside a single unclaimed / unknown author folder, used by the Untracked browse pane |
| GET | /api/untracked/preview?format= |
Stream an EPUB / PDF / TXT file from inside the quarantine bucket for the in-browser preview modal |
| POST | /api/untracked/match-openlibrary |
Match a single file or sub-folder inside the browse pane to an OpenLibrary work, creating the author if needed and moving the file onto disk. The destination is resolved from the file's current location to a real library location (primary/first enabled as a fallback) — never the quarantine path, so matching an item out of a custom __unknown folder files it under the author's library folder instead of burying it in a subfolder inside __unknown |
| DELETE | /api/untracked |
Delete a file or folder under the unclaimed / unknown bucket from disk and prune matching LocalBookFile rows. The Untracked page only asks for confirmation when the deletion is big (> 100 items) or of unknown size (an uncounted sub-folder); single files and small folders delete without a popup |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/locations |
List library locations |
| POST | /api/locations |
Add a library location |
| PUT | /api/locations/{id} |
Update label / path / enabled / primary |
| DELETE | /api/locations/{id} |
Delete a library location |
| GET | /api/settings/incoming |
Read the configured incoming folder |
| PUT | /api/settings/incoming |
Update the incoming folder path |
| GET | /api/settings/openlibrary |
Read the OpenLibrary User-Agent identity (app name + contact email) |
| PUT | /api/settings/openlibrary |
Update the OpenLibrary app name + contact email (stored in the DB) |
| GET | /api/settings/refresh-limits |
Read the refresh-due-works limits (max authors per run, pull-early count) |
| PUT | /api/settings/refresh-limits |
Update the refresh-due-works limits |
| GET | /api/settings/refresh-cadence |
Read the four default refresh cadence buckets |
| PUT | /api/settings/refresh-cadence |
Update the four default refresh cadence buckets |
| GET | /api/settings/duplicate-format-preference |
Read the duplicate-file format priority list |
| PUT | /api/settings/duplicate-format-preference |
Update the duplicate-file format priority list |
| GET | /api/settings/unknown-folder |
Read the optional custom quarantine path (empty = per-location <root>/__unknown) |
| PUT | /api/settings/unknown-folder |
Set or clear the custom quarantine path; migrates contents from the old path and rewrites LocalBookFile.FullPath in the same request |
| GET | /api/settings/pushover |
Read the Pushover app token and user key (both required for alerts to fire) |
| PUT | /api/settings/pushover |
Update the Pushover credentials |
| POST | /api/settings/pushover/test |
Send a test push using the stored credentials, or override-creds passed in the body |
| GET | /api/ignored-folders |
Folder names excluded from every scan |
| POST | /api/ignored-folders |
Add an ignored folder |
| DELETE | /api/ignored-folders/{id} |
Remove an ignored folder |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/nzb-sites |
List NZB search sites |
| POST | /api/nzb-sites |
Add a new NZB site |
| PUT | /api/nzb-sites/{id} |
Update an NZB site |
| DELETE | /api/nzb-sites/{id} |
Delete an NZB site |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/sync/start |
Kick off a full sync (single-flight) |
| POST | /api/sync/seed-authors |
Download and import the OpenLibrary author dump |
| POST | /api/sync/author-updates |
Apply OpenLibrary's daily author updates |
| POST | /api/sync/refresh-due-works |
Re-fetch works for authors with an overdue NextFetchAt |
| GET | /api/sync/status |
Poll current sync phase and counters |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/incoming/process |
Kick off incoming processing (single-flight) |
| POST | /api/incoming/reprocess-unknown |
Re-run matching against __unknown |
| GET | /api/incoming/state |
Poll current incoming run state |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/schedules |
List scheduled jobs and their cron/enabled state |
| PUT | /api/schedules/{jobId} |
Update a job's cron expression or enabled flag |
| POST | /api/schedules/{jobId}/run |
Trigger a job immediately (manualTrigger=true) |
| GET | /api/schedules/{jobId}/history |
Recent succeeded/failed runs for a job (state, finish time, duration) |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/remarkable/status |
Is a reMarkable device paired? |
| POST | /api/remarkable/connect |
Exchange an 8-char one-time code for a device token |
| POST | /api/remarkable/disconnect |
Forget the stored reMarkable credentials |
| POST | /api/remarkable/send/{localFileId} |
Push a local file to reMarkable |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/openlibrary/search-authors?q= |
Proxied OpenLibrary author search |
| GET | /api/openlibrary/suggest-for-folder?folder= |
Top-N OL author candidates for a folder name, ranked by Jaro-Winkler over the normalised name (used by the "Suggest from OL" button on the Untracked page) |
| Method | Path | Purpose |
|---|---|---|
| GET | /opds/catalog.xml |
Root navigation feed |
| GET | /opds/authors.xml |
All active authors |
| GET | /opds/authors/{id}.xml |
One author's works |
| GET | /opds/missing.xml |
Unowned books from starred authors (up to 200) |
| GET | /opds/recent.xml |
Recent releases from starred authors (up to 200) |
| GET | /rss/recent.xml |
RSS 2.0 feed of new releases (starred authors; ?all=true for every tracked author) |
The Hangfire dashboard is served at /hangfire (no auth — intended for a
trusted LAN).
Author— OL key, name, library folder name,CreatedAt(DB-stamped audit time) andCreationSource(what created the row: manual / same-name / assign / content-scan / adopt / restore — shown as a "via …" tag on the Authors page), status (Pending / Active / Excluded / NotFound), exclusion reason, priority (0–5), bio (from OL), last-synced timestamp, next-fetch-due-at,CalibreScannedAt(for fair scan ordering),RefreshIntervalDays(optional fixed cadence override in days),LinkedToAuthorId(self-referential FK to the canonical author when this row is a user-marked duplicate or pen name;ClientSetNullon delete so parent removal nulls children rather than cascading),IsPenName(whenLinkedToAuthorIdis set, distinguishes "fold into canonical" from "show separately, just back-reference"),NotifyOnNewBooks(per-author opt-in for Pushover alerts when a refresh inserts a new book published in the current or previous year).Series— normalised series name, optional FK to primaryAuthor, optionalParentSeriesIdself-referential FK (up to 5 levels deep),PositionInParentstring for reading-order sorting within the parent. A series can be shared across authors (e.g. co-written or continued by another writer).SeriesAuthor— join table linkingSeries↔Authorfor additional/co-authors beyond the primary.Collection— a user-defined shelf (unique normalised name).BookCollectionis theCollection↔Bookjoin (cascades from either side). Genre tags are not stored — they're derived on the fly fromBook.Subjects.Book— OL work key (unique per author; a syntheticXX…Wkey for manually-added books not yet on OpenLibrary), title, first-publish year,CoverId(OpenLibrary cover) andCoverUrl(custom cover, mainly for manual books),ManuallyOwnedflag + timestamp (a physical/print copy with no file),OwnedDifferentEditionflag + timestamp ("got but in a different edition than catalogued" — no file here),Subjects(semicolon-delimited OL subject tags;NULL= never checked,""= checked/none found),SeriesId(FK toSeries),SeriesPosition,ReadStatus(Unread/Reading/Read/Dnf),ReadAt,Wanted,Suppressed(user-hidden; rendered in a collapsed section at the bottom of the author detail page, never deleted),Isbn(ISBN-13 preferred, normalised on insert), FK to Author.CreatedAt(when the library first saw the book — drives the Recent Releases by-day grouping). DB-defaulted to the insert time, except a book first seen with a publish year already in the past is stamped 1 Jan of that publish year (Book.CreatedAtForPublishYear) so an old title can't surface as a brand- new release; a book published this year keeps the live insert time.LocalBookFile— path on disk (file path after organizer runs, directory path in classic library layout), library folder names, optional FKs to Author and Book (null FK = unmatched),Isbn(extracted fromdc:identifierwhen the EPUB has one),AdditionalBookIds(comma-separated list of secondary Book ids for omnibus / boxed-set files; the primary Book stays onBookId).LibraryLocation— a root directory to scan. Multiple allowed; exactly one isIsPrimary. Each has a label, enabled flag, andLastScanAt.AppSetting— key/value store (incoming folder path, schedule config, custom quarantine pathUnknownFolder,PushoverAppTokenandPushoverUserKeycredentials, etc).IgnoredFolder— author-level folder names to skip on every scan (case-insensitive).__unknownis always skipped automatically.AuthorBlacklist— normalized author names that are never promoted to the watchlist, with optional folder name and reason fields.TitleBlacklist— normalized junk titles ("Scanned", "@page", "A Novel", …) the content-scan job refuses to keep as a guessed title.BlockedBookLink— a (file path, book id) pair permanently blocked from being re-linked after the Duplicates page's Unlink action; see Blocked book links.NzbSite— a named NZB site with a URL template containing{Title},{Author}, and/or{SearchTerm}placeholders; has order and active flag.ScheduleEntry— cron expression + enabled flag, keyed by job id.AuthorUpdateState— watermark for the OpenLibrary author-updates feed.RemarkableAuth— singleton row holding the paired reMarkable device token, cached user token + expiry, device GUID, and last-sent timestamp.PhysicalBookUnmatched— rows from a physical-books import that couldn't be matched against any tracked book. Fields: Author, Title, SeriesPos, Isbn, AddedAt. Indexed by(Author, Title)for the dedupe check on re-import. Editable, resolvable, and re-runnable from/physical-unmatched.
cd TheLibrary.Server.Tests
dotnet testThe suite is xUnit-only with no DB dependency — every test runs against in-memory inputs.
AuthorMatcherTestscovers the author-matching algorithm end-to-end: forward and reverse filename patterns,"Last, First"metadata, diacritics, surname/forename rotations, folder-layout ancestor walks, the tracked-wins-over-OpenLibrary precedence rule, and theAlternateNamesindex expansion.TitleNormalizerSeriesTestspins every series-filename shape the parser must handle (86 cases across 12 patterns) plus the helpers (Normalize,NormalizeAuthor,FolderTitleCandidates,IsPlausibleAuthorName).SeriesPositionParserTestscovers OL-title parenthetical extraction for(Series, #N),(Series, Book N),(Series, Part N),(Series, Vol. N), fractional positions, and the known-limitation edge cases.UnknownFolderRecoveryTestscovers the__unknownrematch planner: comma-form, surname-first rotation, alternate-name lookup, diacritic stripping, blacklist gate, OL-only entries being ignored, tracked-wins-over-OL on collision, and duplicate-input handling.AuthorFolderNameResolverTestscovers the same-name disambiguation rule: no collision → bare name; collision with full OL keys → suffix on all members; collision with any missing key → no suffix yet; linked pairs not counted as collisions; sibling pen names of the same canonical not counted; diacritic-equivalent names still collide; three-way collisions all suffixed.AuthorPrefixStripTestscovers the<Author> - <Title>/<Title> - <Author>filename rewriting that runs before sync's auto-matcher evaluates a stem, plus the series-filename signal that adds the parsed title as an extra candidate.FuzzyScoreTestschecks Jaro-Winkler behaviour: identical→1.0, typos≥0.85, distinct strings <0.7, scores always in [0,1].IsbnNormaliseTestschecks the ISBN extractor's handling of hyphenated / spaced / URN-prefixed forms plus the trailing 'X' check digit and rejects too-short/too-long inputs.FilePreviewResolverTestscovers the streaming-preview path resolver: format whitelist (epub/pdf/txt only), single-file vs directory layout, multi-format directories picking the requested extension, double-dot path canonicalisation that stays inside the root, escape attempts that exit the root, and root-prefix collisions (/books/Collvs/books/Collection).
259 tests total at the time of writing.
cd TheLibrary.Server
dotnet ef migrations add DescriptiveName --output-dir Data/MigrationsIt gets applied on the next server start — no manual database update.
- The connection string must not be committed. Use user secrets, environment
variables (
ConnectionStrings__Library), or a deployment-time secret store. - The OpenLibrary contact email and app name are not stored in any file — they live in the database and are set on the Settings page, so they never enter the repo; see Identifying the application. Never reuse another deployment's OpenLibrary identity — the email is a contact address that points OpenLibrary at whoever's name is on it.
.gitignoreexcludesbin/,obj/,*.user,.vs/,secrets.json,.env*, and anyappsettings.*.Local.json/appsettings.Production.json.appsettings.jsonships with an emptyConnectionStrings:Libraryon purpose — if the server fails to start with "Missing ConnectionStrings:Library", you haven't set the user secret yet.- The Hangfire dashboard authorizes all callers — it's intended for a trusted
single-user LAN deployment. Swap
AllowAllDashboardAuthorizationFilterfor something stricter if the host ever moves to a shared environment.
