# Changelog All notable changes to Bindery are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com) and versions follow [Semantic Versioning](https://semver.org). ## [v1.28.1] — 2026-07-27 A bug-fix release, and most of it is one bug wearing different hats: two sides of a string comparison were being reduced through different alphabets, so they could never match. That single shape made accented and non-Latin books unfindable, split authors into duplicate rows, stopped the library scan reconciling files it had already imported, and merged unrelated series together. Those folds now live in one place, with a test suite that fails the build when two of them disagree rather than waiting for the bug report. Alongside that: the install-wide **Default monitor mode** is finally honoured by the bulk importers that were ignoring it, the Search page stops offering movie rips and usenet fragments with a Grab button next to them, and every Settings field confirms when it saves. ### Fixed - **Accented and non-Latin titles and authors return results again** (#1642) — searches for a book whose title or author had a diacritic at the start or end of a word ("Ölümün Sonu", "El mundo que Jones creó", "Édition collector"), or that used a non-Latin script (Chinese, Cyrillic, Greek, Hebrew, Arabic), matched nothing at all — even when an indexer returned a release named exactly like the book. Measured against a real library, 110 of 382 books with a non-ASCII title could never be found, and they stayed in the wanted list being re-searched on every sweep. The matcher's word-boundary rule only understood ASCII letters, so a keyword touching a non-ASCII character was impossible to match; it now understands the full Unicode alphabet. Single-word titles by an author with a non-Latin name (for example "Circle" by 刘慈欣) were affected too, because those searches require the author to match. Accents *inside* a word ("Miéville", "Stanisław") were never affected. - **Authors with dotted initials match releases again** (#1608) — a name like "J.R.R. Tolkien" produced an author token ("j.r.r") that can never appear in a normalized release name, so searches for single-keyword titles ("The Hobbit") returned zero results even when indexers found dozens. Author names are now normalized the same way release titles are before tokenizing; hyphenated first names ("Mary-Kate") are fixed by the same change. - **Books with an apostrophe or umlaut in the title no longer waste indexer queries** (#1643) — a search for a title like "Ender's Game", "The Handmaid's Tale" or "Der Prozeß" threw away a perfectly good indexer response, misreading it as an irrelevant category feed, and then re-queried the same indexer three more times. This happened on every search for those books, and on a rate-limited indexer it could turn into a hard failure that returned no results at all despite the correct release having already been found. The check now compares both sides of the match in the same form. - **"Default monitor mode" now applies to every way an author gets added, not just the Add Author button** (#1666) — the setting says "Applied to newly added authors", but only the manual Add Author form ever read it. Every import created the author with no mode at all, which the database quietly turned into "all books", so the one setting people reach for before a bulk import did nothing on exactly the paths they were trying to protect. Two reports: a Calibre library imported from scratch with the default set to None still produced authors monitoring everything, and a CSV author import queued about 1250 books for download. Calibre, Audiobookshelf, CSV, Readarr and Goodreads imports, the series backfill and the author created behind a manual Add Book all honour the setting now, including the latest-book count. Anything that already picks a mode on purpose keeps it, so Hardcover list sync still monitors only the books on the list, and existing authors are untouched. - **The Search page no longer offers movies and usenet junk to grab** — the video and non-book guards added for #1591, and later extended to the search-details pipeline in #1644, were missing from the freeform search behind the **Search** page. A query could return movie rips (`1080p`, `x265`, `S01E02`, releases the indexer itself filed under Movies/TV) and raw per-article usenet postings (`.part03.rar`, `[12/22]`, `yEnc`), each with a Grab button next to it — and grabbing one imports the wrong file. The same two content guards the book searches use now apply here. Relevance filtering is deliberately not applied: a freeform query has no book to score against, so which results are relevant stays the user's call. - **Interactive search no longer shows video releases the automatic grabber rejects** (#1644) — the guard that keeps movie and TV releases out of book results was applied to scheduled searches but was missing from the search you run yourself, which is the one the UI uses. Releases tagged 1080p/x264/WEB-DL, or filed by the indexer under Movies, TV or similar, could therefore appear in manual search results and be grabbed by hand. The search details panel now also reports how many results that stage removed. - **Library scan reconciles books whose author folder writes initials without spaces** (#1646) — a folder named "J.R.R. Tolkien" never matched the catalogue's "J. R. R. Tolkien", because the run-together form produced a token no author name can contain. Audiobook releases write initials this way almost universally, so those books stayed unmatched across every rescan. Manual import was unaffected, which is why it looked like only the scan was broken. - **Library scan matches accented names and titles regardless of how they are encoded** (#1646) — macOS reports filenames with accents stored separately from the letter they sit on, while every metadata provider returns them combined. Nothing in the scanner reconciled the two, so "Björn Andersen" on disk never met "Björn Andersen" in the catalogue. Titles had a worse version of the same bug: "Die Höhle" was being reduced to "die hle" before comparison. - **Library scan matches German titles spelled either way** (#1646) — "Die Hoehle" on disk now matches "Die Höhle" in the catalogue, and "Der Prozess" matches "Der Prozeß", the same way the indexer already matched them. Apostrophes too, so an "Enders Game" file finds "Ender's Game". - **Bulk folder import matches transliterated author names** (#1646) — "Boell, Heinrich" now resolves to "Heinrich Böll" instead of scoring far too low to be considered a match. - **Calibre import stops creating duplicate authors** (#1647) — Calibre compared author names byte for byte, so an author already in your library from Audiobookshelf or OpenLibrary got a second row whenever Calibre spelled the name differently ("J.R.R. Tolkien" against "J. R. R. Tolkien", or an accented name written in a different Unicode encoding, which Calibre on macOS produces). The books then showed up split across two author pages, and every later import recreated the split. - **Missing descriptions, covers, ratings and genres get backfilled again** (#1647) — the enrichment step matched candidate books by comparing raw author and title strings from two different providers, so any book whose providers disagreed about initial spacing or accent encoding was never enriched. Because the comparison always failed the same way, it never recovered on retry. - **A German author from DNB no longer ends up alongside a duplicate from OpenLibrary** (#1647) — the check that exists to merge those two rows compared "Tolkien, J. R. R." against "Tolkien, J.R.R." and found no match, so it created exactly the duplicate it was meant to prevent. Case handling for non-English letters was broken in the same query. - **German and Scandinavian authors no longer end up duplicated or stuck in the review queue** (#1647) — author matching stripped accents ("Müller" → "muller") while every title matcher expands them ("Müller" → "mueller"), so a name spelled with accents in one place and ASCII-ised in another ("Jörg Müller" vs "Joerg Mueller") scored just under the auto-accept threshold. The same gap affected "Nesbø"/"Nesbo" and "Łukasz"/"Lukasz". Both spellings now resolve to one author. - **Non-Latin series no longer collapse into one shared series** (#1645) — a series whose title had no Latin letters (Japanese, Chinese, Russian, Greek, Hebrew, Arabic) produced an empty identity, so the first such series imported claimed a shared row and every unrelated non-Latin series afterwards was filed under it. Accented Latin could collide the same way ("Ödland" and "Ådland" were treated as the same series). Series identities are now built from the full Unicode alphabet, with accents folded to their base letters, and a title with no letters or digits at all is skipped instead of being given a placeholder identity. Creating a series with an empty identity is now rejected outright, so no future import can reintroduce the same merge. - **Japanese, Hindi and Greek series no longer merge into each other** (follow-up to #1645) — the fix above folded accents by stripping every combining mark, which is only correct for Latin. In Japanese those marks are part of the letter, so "ハード" (hard) and "ハート" (heart) both became "ハート" and shared one series row. Hindi vowel signs are spacing marks, so a Devanagari title was chopped into fragments instead of being kept whole. And a Greek title in all-caps keyed differently from the same title in mixed case, splitting one series in two. Accents now fold for Latin and Greek only — the two scripts where they really are accents — and every other script keeps its letters intact. Latin letters that carry no accent to strip (ß, ø, ł, æ, œ, þ, ð) now fold in slugs too, so a series is no longer split by spelling ("Straße-Serie" and "Strasse-Serie" are one series). Existing ASCII series identities are unchanged. - **Accented titles no longer produce two different indexer searches** (#1648) — the same title could be sent to indexers in two different encodings depending on where it came from, which also gave it two different deduplication keys. Found by the new consistency test, not by a bug report. - **Books stored with a format tag in the title stop getting a duplicate row** (#1648) — a book saved as "Title [Unabridged]" was not recognised as the same work as OpenLibrary's "Title", so an author sync created a second row for it. - **Series named "... Series" or tagged "[Audiobook]" can be upgraded to full Hardcover data** (#1648) — the lookup and the upgrade check disagreed about what counts as the same series name, so some series matched one and failed the other. - **Every settings field now confirms when it saves** (#1668) — the little green "Saved ✓" only appeared on some fields. Saving the book naming template, the audiobook folder template, the per-track audiobook template, either API key, or the log retention days gave no feedback at all, so there was no way to tell a save had gone through short of reloading the page. All of them confirm now, and there is one shared button behind them rather than a copy per field, so the next one can't quietly go missing. Failed saves show "Error" instead — three of those fields were swallowing the failure entirely, which would have shown a green tick on a save that never happened. - **OIDC callback URLs behind trusted proxies** — redirect auto-detection now retains the original trusted proxy identity after real-client-IP resolution, preventing HTTPS callbacks from incorrectly falling back to HTTP. ### Changed - **A non-Latin word now breaks phrase matching the same way an English one does** (#1642) — previously a "contiguous" title phrase would silently skip over an intervening Cyrillic or CJK word, because the matcher treated every non-ASCII letter as punctuation. Such titles are now handled by the same gap-tolerant path that already handled their English equivalents, so nothing is lost. - **Normalization rules are defined in one place** (#1648) — the character folding used to compare titles and release names was copy-pasted into three packages and had started to drift, which is what caused #1643 and made #1642 possible. It now lives in a single shared helper, with the four legitimately-different comparison alphabets (title matching, author identity, sort keys, slugs) documented alongside it. No change to how releases match. - **Author name handling is defined in one place** (#1648) — several helpers that build author search queries and sort names existed as identical copies in four packages. They now live in one, and a new test suite compares every normalization rule in the codebase against a corpus of awkward names and titles (dotted initials, possessives, umlauts, Nordic and Polish letters, CJK, Cyrillic, Greek, Hebrew) so that two of them disagreeing is a build failure rather than a bug report months later. ## [v1.28.0] — 2026-07-25 A feature release driven almost entirely by user reports. Getting an existing library in gets a **recursive bulk import** and a **manual import wizard**; private-tracker users get a **per-indexer freeleech policy** that holds ratio-costing releases for approval instead of hiding them; an accidental mass import can now be undone with **bulk queue removal**. Alongside those, a multi-user ownership leak in Hardcover list sync is closed, and a batch of durability fixes stop a failed database write from being reported as a successful import and stop background jobs racing the database on shutdown. ### Added - **Bulk select and remove queue items** (#1622) — the queue could only "clear all failed", so an accidental mass import (one report: a CSV author import with monitoring left on, which queued ~1250 books) had no practical undo: the Books and Wanted bulk actions select one page at a time, and removing a queue item resets its book to wanted — so with the books still monitored the scheduler simply grabbed them again on the next sweep. The queue page now has per-row checkboxes and a **select-all that spans the entire queue, not just the visible page**, plus a bulk **Remove selected** with two options: *Also stop monitoring these books* (on by default — this is what stops the re-grab) and *Delete downloaded files* (off by default, so data and torrent seeds are kept). Backed by `POST /api/v1/queue/bulk-delete`, which removes each item from its download client under a bounded fan-out and reports a per-id result so one stale id can't fail the whole batch. - **Per-indexer freeleech policy: hold ratio-costing releases for approval** (#1624) — a private-tracker user near a ratio floor previously had to restrict the whole indexer to Freeleech/VIP upstream in Jackett, which also hid normal releases from interactive search (where they'd happily pay the ratio cost on a book they actually want) and did nothing for bulk multi-book search, which has no picker and is pure fire-and-forget. Indexers gain an opt-in **Only auto-grab freeleech releases**: automatic grabbing takes only releases the tracker reports as freeleech (torznab `downloadvolumefactor` of 0 — an attribute Bindery previously never read), and anything that would cost ratio is parked in the existing **Pending** queue for manual approval rather than hidden or grabbed blind. Interactive search is deliberately untouched and still shows everything. Per-indexer rather than global because ratio economics are per-tracker — public trackers and Usenet are unaffected. A release whose ratio cost the indexer doesn't report is held rather than grabbed, on the principle that a visible hold is recoverable and an over-spent ratio isn't; half-leech (0.5) is held too. - **Recursive bulk folder import** (#1434, closes #1402) — the bulk scan enumerated only immediate children and treated every subdirectory as a single unit, so a `Author/Books/Title.epub` layout collapsed into one row and dropped files, and any directory was labelled "audiobook" regardless of contents. The scan now walks recursively, yields per-file units at any depth, decides the directory-as-unit boundary by inspecting what's actually inside (audio vs ebook), derives the author from the folder layout, and matches on embedded EPUB metadata first, then folder author, then filename. A lone loose single-title match with no author corroboration is demoted from *confident* to *ambiguous*, which is the box-set mismatch reported in #1402. - **Manual import wizard** (#1236) — a new `/import` page scans a folder, groups the results into *confident* / *ambiguous* / *unmatched*, and lets each unit be resolved individually (accept the match, pick from candidates, or search the existing catalogue) with a per-unit format override, then imports the resolved set in one batch. - **Books view sorting and filtering** (#1349) — clickable sortable column headers (author, type, status, title, date; ascending↔descending, whitelisted server-side) and an all/monitored/unmonitored filter, mirroring the Authors view. Additive — no migration. - **Per-file audiobook renaming** (#1126) — ebook imports have always renamed per file, but audiobook folders were copied in verbatim, so a library with a naming template still ended up with whatever track names the release shipped. A new `naming.audiobook_file_template` (empty = off, unchanged behaviour) flattens the audiobook folder on import and renames each track in resolved playback order, with the `{Part}` token carrying the index. It shares one deterministic track-ordering and sidecar-carry path with the existing multi-disc flatten, and works across copy, hardlink, and move. A template without `{Part}` is rejected when saved — and defensively fails the flatten rather than collapsing every track onto one filename. - **Ebook + audiobook drop-folder pair gating** (closes #942) — completes the drop-folder handoff started in #941. A new `import.drop_pair_gating` opts `media_type=both` books into hold-until-paired handoff: the first format to arrive is parked in a non-terminal held state (files left in place, and kept out of the re-grab loop) until its sibling completes, then both are dropped together so a paired-reader tool ingests them as one. If the sibling never arrives, `import.drop_pair_gating_timeout_hours` (default 72) releases the held format on its own. - **Ebook language detection at import** (#1160) — `dc:language` is now read from the imported EPUB and canonicalised, along with provider-supplied codes, to the ISO 639-2/B vocabulary the metadata-profile language filter already uses (`en`/`en-US` → `eng`, `zh-Hans` → `chi`). At import the ebook branch backfills a book's language when the catalogue left it empty, so the library and the language filter reflect what the file actually is. Locked fields are never overwritten. - **Strict media-type policy** (#1575) — a new `default.media_type_strict` setting (off by default). Under a single-format default, a work available in both formats is narrowed to the configured one, and a work available *only* in the other format is skipped at add/refresh time instead of being created as a row that can never be satisfied (Discussion #1572). Skips are counted into the author-sync summary log, so the outcome is visible in the Logs tab without a notification per book. ### Security - **Hardcover list sync now stamps an owner on the books and authors it creates** (#1621) — the follow-up to the v1.25.0 ownership work (#1457), which covered every create path *except* this one. Because the list syncer set no `owner_user_id`, everything it created was NULL-owned — and a NULL owner reads as global, so books synced from one user's Hardcover list were visible to **every** user, while manually added books stayed correctly private. Import lists are admin-configured and sync on a background schedule with no request identity to stamp from, so ownership now lives on the list row: each import list gains an **Owner**, and the syncer stamps that owner onto the books and authors it creates. An existing author reused by a sync keeps whatever owner it already had, so a list can never reassign another user's — or a shared — author. Leaving the owner unset means *Global*, which is the previous behaviour and what every existing list gets, so nothing changes until an owner is chosen. Only affects deployments with `BINDERY_ENFORCE_TENANCY` enabled. ### Fixed - **Usenet ebook imports no longer leave an empty job folder behind** (#1623) — a single-file ebook grabbed via SABnzbd/NZBGet imported cleanly but left its completed job folder sitting empty under `complete/`. Usenet clients report the storage path of a single-file job as the *file*, not a directory, and the move-mode cleanup only ever pruned directories at or below the download path — which a file's parent is not, so the folder was never even a candidate for removal. Cleanup now prunes from the parent directory when the download path is the imported file itself. The existing protections are unchanged: a folder still holding anything else is kept, and the category directory above the job folder is never touched. (Torrent imports and audiobook folder imports were never affected, which is why audiobooks looked fixed as of v1.26.0.) - **Audiobook import no longer reports success after a failed database write** (#1459) — the audiobook branch moved or copied the folder, recorded the format path on a best-effort basis, and then marked the download imported regardless of whether that write landed. With the path unrecorded the book still read as wanted, so the next sweep re-grabbed it into a `Title (2)` duplicate — and in move mode the original source was already gone. The write is now retried on transient SQLite lock/busy errors and, if it still fails, the import is marked blocked rather than falsely completed. - **Background jobs are drained before the database closes on shutdown** (#1458) — the ABS import, Grimmory sync, and manual library scan ran on detached contexts and raced `database.Close()` on SIGTERM, producing "database is closed" errors and making Grimmory re-upload everything after each deploy, since the push is only recorded once it succeeds. Those jobs now run on a shutdown-scoped context that is drained, within a bounded grace window, after HTTP shutdown and before the database is closed. - **ABS import keeps its resume checkpoint across a shutdown** (#1472, partial) — the enumerator flushed its resume checkpoint on the very context that had just been cancelled, so the flush failed and the progress it existed to preserve was discarded. It now writes on a detached context. Previously unreachable, and made live by the shutdown work above. - **NZBGet queue removal honours "keep files"** (#1456) — removing a download sent `DeleteFinal`, which isn't a valid editqueue command and ignored the flag entirely. Keeping files now sends `GroupParkDelete` and deleting them sends `GroupDelete`. Requires NZBGet 17 or newer. ### Changed - **Shutdown grace defaults lowered** (part of #1458) — the HTTP shutdown grace drops from 30s to 10s and the background-job drain window is 15s, so the two serial waits total 25s and fit inside the chart's 30s `terminationGracePeriodSeconds` instead of being cut short by it. Operators who raised that value can raise these to match. ## [v1.27.0] — 2026-07-22 A feature release built around library management. Two new tools for keeping an existing library tidy — **Rename files** (reorganize files to the current naming template) and **Match to book** (rescue a completed download the auto-matcher couldn't place) — plus a guard against auto-grab pulling in unrelated movie/TV releases and a fix for German National Library catalogues showing one book per printing. ### Added - **Rename files: reorganize an existing library to the naming template** (#1181) — the naming template used to apply only at import time, so changing it later, importing a pre-existing library, or ending up with a half-renamed mix left no way to reconcile files short of deleting and re-importing. A new **Rename files** action (on the book detail page and the author page) previews every tracked file's `current → proposed` path under the current template and, once confirmed, moves the ones that differ — updating the library index and recording a rename in history. It is always a move within the library (never a copy), refuses to overwrite an existing destination, skips files already in the right place, and leaves anything not on disk untouched. Ebooks move as single files; audiobooks move as whole folders. Backed by `GET /api/v1/reorganize/preview` and `POST /api/v1/reorganize/apply` (both admin-only). Whole-library scope is available through the API; the UI exposes the per-book and per-author scopes. - **Manually match an unmatched download in the queue** (#1589) — a download the auto-matcher couldn't tie to a book ("could not match any book to this download") used to sit in the queue as a dead-end import failure. Import-failed items now have a **Match to book** control: search your library, pick the book the files belong to, and Bindery imports the already-downloaded files against it — attaching the file and flipping the download to imported, with inline feedback. The scanner now records where an unmatched download's files are (it previously discarded that path), so the match can import them directly instead of hoping the download client still remembers the release; downloads without a recorded path fall back to a client re-poll. Downloads the scanner terminally blocked after exhausting their import-retry budget (the "stuck after three attempts" case) are recoverable too — the Match to book and Retry import controls now appear for `importBlocked` items, and a match re-imports the recorded files (or re-arms the scanner with a fresh retry budget) instead of leaving them permanently stuck. ### Fixed - **Auto-grab no longer selects or imports unrelated video releases** (#1591) — an automatic audiobook search could pick a movie, TV, or music release that shared a few words with the book title, download it, and move the whole folder (video file included) into the audiobook library marked as imported. Two new guards close this: release names carrying video-only markers (`1080p`, `x265`, `WEBRip`, `S01E02`, and similar) and results the indexer filed under a movie/TV/console/PC category are now dropped from book search results; and at import time, a download whose largest file is a video file is blocked for manual review instead of being imported (an explicit format chosen through manual import overrides the block). - **DNB author catalogues collapse editions into one book per work** (#1585, #1586) — the Deutsche Nationalbibliothek issues one MARC record per edition, printing, and volume with no work abstraction, so adding a DNB-primary author's catalogue produced a wall of near-duplicate books for the same work. The author-works path now groups records by work and volume, drops the combined volume-0 record when a work has numbered volumes, and rebuilds each representative's title from its series/volume statement, so one work becomes one book. Existing libraries keep any duplicate rows already imported (the author sync is add-only); remove them manually or via a future merge (#1358). - **Security: the wanted/missing list is now scoped per user** (#1600) — under multi-user tenancy (`BINDERY_ENFORCE_TENANCY`), `GET /api/v1/wanted/missing` returned every user's wanted/missing books to any non-admin instead of just their own, the one book-list route that had missed the `owner_user_id` scope applied everywhere else. It now filters like the main book list; admins, API-key, and single-tenant deployments are unaffected. ### Changed - The queue **Retry import** control now also revives downloads the scanner terminally blocked (`importBlocked`) after exhausting their retry budget, not only `importFailed` ones — previously it silently no-op'd on blocked items. (Part of #1589.) - **Dependencies:** `modernc.org/sqlite` 1.53 → 1.54, the distroless base image for the main binary and the discord-stats/telemetry sidecars, and seven minor/patch web dependencies (#1582, #1583, #1584, #1554, #1555). ### Docs - Clarified that `BINDERY_LIBRARY_DIR` is a scan/reconcile target in External import mode, not a destination Bindery writes to (#1558). ## [v1.26.2] — 2026-07-19 A patch release fixing two reported metadata bugs: profile language filters that never applied to what actually got downloaded, and DNB-primary setups silently importing English OpenLibrary catalogues. ### Fixed - **Metadata profile languages now constrain downloads, not just the catalogue** (#1573, #1576, Discussion #1572) — the profile's `allowed_languages` filtered which books entered the library, but the release filter only activated for English-only profiles and the automatic grab path never loaded the profile at all, so an ITA/ENG profile still grabbed German or French releases whenever one ranked first. The foreign-tag list now maps each release marker to its ISO 639-2/B code so any profile language set can be checked against a release: tagged with a language outside the set drops it, untagged passes (the tag is only ever a negative signal). Auto-grab resolves the author's profile before grabbing, mirroring the interactive search, with the global `search.preferredLanguage` setting as the fallback. Closes #1573. - **DNB as primary provider now keeps the DNB author identity** (#1574, #1577) — author search collapses same-name records from multiple providers to the most complete one, judged by work and rating counts that only OpenLibrary reports, so the `dnb:` record lost the collapse every time even with DNB promoted to primary. The added author kept an OpenLibrary foreign ID, and since the catalogue imports from the provider the foreign ID names, DNB-primary users got English OL work titles — and book searches built from those titles could never match German releases. The primary provider's record now wins the collapse ahead of the count comparisons; OpenLibrary-primary setups are unchanged. Authors already imported under the OL identity can be relinked once via **Link metadata** on the author page. Closes #1574. ## [v1.26.1] — 2026-07-18 A patch release fixing three reported bugs: dual-format downloads serving the wrong file, combined Audiobookshelf libraries dropping their ebooks on import, and the return of the author-sync foreign-key warning burst. ### Fixed - **Downloading a dual-format book now serves the format you selected** (#1561, #1563) — the UI sends `?format=ebook|audiobook` when a book has both formats, but the download handler never read the parameter and always walked its legacy path chain, so selecting Audiobook still delivered the epub whenever an ebook file existed. `GET /api/v1/book/{id}/file` now scopes the file selection by the requested format, using the same query-parameter contract the delete endpoint already had. Requests without the parameter keep the old behaviour, and books imported before the dual-format schema satisfy a format-scoped request from their single legacy path only when its on-disk shape matches (directory = audiobook bundle, file = ebook). Closes #1561. - **ABS import now picks up ebooks that Audiobookshelf marks supplementary** (#1565, #1566) — a combined item (epub stored next to the audio files in one ABS library item) imported only the audiobook when the ABS library has "Audiobooks only" enabled: ABS then never promotes the epub to `media.ebookFile`, exposing it only as a supplementary `libraryFiles` entry, and Bindery read `media.ebookFile` exclusively. The normalizer now falls back to the supplementary ebook (preferring `.epub`, mirroring ABS's own primary-ebook selection), so both editions import — no extra API calls, since the expanded detail fetch already carried the data. A promoted primary ebook still wins. Reported in Discussion #1556 on ABS v2.35.1; closes #1565. - **Author catalogue sync stops mid-flight when its author is deleted** (#1559, #1564) — the async sync runs against a snapshot of the author and can spend minutes fetching a prolific author's catalogue. If the row was deleted in that window (the Add Book orphan cleanup after a poll timeout where #808's direct insert didn't land, or a user deleting the author mid-refresh), the insert loop failed the `author_id` foreign-key constraint once per work — the reported burst logged 180 warnings for a single "Michael Lewis" sync. The sync now re-checks the author row after the slow fetch phase and aborts on a foreign-key failure whose author has vanished, logging a single line instead. Harmless before and after (the failed inserts were never persisted), but the noise made real errors easy to miss. Closes #1559. ## [v1.26.0] — 2026-07-14 A correctness release. Two independent reports pinned real bugs in the parts of Bindery that decide *what to grab* and *what to do with it afterwards*: the relevance filter could grab a completely different book whose title merely contained yours, and usenet imports were quietly leaking every completed download folder to disk forever. Both are fixed. Around them, this release is mostly about Bindery telling you the truth when something is wrong — storage health, VPN-blocked connection tests, and Grimmory's connection probe all stop hiding behind generic errors — plus a search-first Add Author flow and a Calibre path-remap escape hatch for mismatched container mounts. ### Added - **Calibre push path remap** (#1346) — Bindery hands the Bindery Bridge plugin the exact path it stores each book at, and the plugin opens that path on *its* side of the container boundary; when the two containers mount the library at different points (the recurring Unraid case), every push failed with "No such file or directory". A new **Settings → Calibre → Push path remap** field (plugin mode) translates Bindery's library prefix to the Calibre container's before the push, using the same `from:to[,from:to]` grammar as `BINDERY_DOWNLOAD_PATH_REMAP` — e.g. `/books:/mnt/user/media/books`. Malformed pairs are rejected at save time; empty means no translation, and aligning the mounts remains the preferred zero-config setup. ### Changed - **Search-first author acquisition** (#1227, #1516) — select an author result before reviewing monitoring and download options, with consistent localized add dialogs and inline errors. Stage 1 of unifying the add and search acquisition flows; the issue stays open to gather feedback before the next stage. Community PR from @magrhino. - **Base image and dependencies refreshed** (#1527, #1528, #1529) — the distroless `static-debian12` base moves to its current digest, alongside the usual grouped Go and frontend dependency bumps. ### Fixed - **Usenet imports no longer leak completed job folders or library receipts** (#1542) — `import.mode` was applied with no protocol distinction, so usenet downloads inherited hardlink/copy behaviour whose only purpose is preserving torrent seeding. The completed job folder was left behind forever — and invisibly, since post-import cleanup removes the client's history entry but not its files (one report: 2.4 GB orphaned from three audiobook grabs). SABnzbd/NZBGet downloads now resolve `auto` and `hardlink` to `move` (explicit `copy` stays honoured for operators who want the client's retention to see finished files). Separately, directory placements copied the whole job tree verbatim, so `.nzb` receipts and `.par2` repair files landed in the library next to the media: hardlink, copy, move, and multi-disc-flatten placements now all skip download artifacts (`.nzb`/`.par2`/`.sfv`/`.srr`/`.srs`/`.diz` — `.nfo`, covers, and cue sheets are deliberately kept). Multi-disc flattening also works under move mode now (flatten via copy, then remove the source), so usenet downloads resolving to move don't lose it. Reported by cleb on Discord with both root causes correctly identified. - **Wrong-author grabs from embedded title phrases** (#1539) — the release relevance filter accepted any release whose name contained the book's title words as a contiguous phrase, with no author check on that path, so a different work embedding the requested title could be grabbed and imported ("Reborn as an Assassin's Apprentice, Vol. 1 by okiuta" matched for Robin Hobb's "Assassin's Apprentice"; reported by cleb on Discord with the root cause pinned). Phrase and in-order title hits are now only trusted on their own when *anchored* — preceded by nothing but the author, a series index ("Book 1", "Vol. 2"), numbers, or filler words. When real foreign words sit in front of the title (usually another work's longer title), the requested author must appear somewhere in the release name. Releases titled with just the book title still pass, so the fix costs no recall on the common author-less naming shapes; the narrow tradeoff is that a release naming only a series *name* (no author, no "Book N" marker) before the title is now rejected rather than risk importing the wrong book. - **Bulk searches no longer burst your indexers** (#1515) — "search all wanted" for a prolific author, filling a series, per-author auto-search, and the scheduled wanted loop now pace their indexer searches (a short gap between each) instead of firing as fast as slots free up. A 30-book author could previously flood a rate-limit-free Prowlarr into dropping requests, so nothing got grabbed; the fan-outs still run with the same concurrency caps but no longer sustain a tight query loop. - **Transmission polling on large torrent histories** (#1524) — the download poller read at most 1 MiB of Transmission's `torrent-get` reply, so an instance with a few thousand torrents (one report: ~12 MiB for 5000+) had its response silently truncated and every poll failed with "unexpected end of JSON input", blocking imports. The RPC read cap is now 64 MiB, and a reply that somehow still exceeds it returns a clear "too many torrents to poll in one request" error instead of invalid JSON. - **Grimmory "Test connection" against Grimmory v3.x** (#1485) — the connection test probed `GET /api/status`, a route current Grimmory (v3.x) no longer has. Its Spring security layer answers any unmapped `/api/**` path with a 401 Whitelabel page, which looked like an auth wall (and was mistaken for one in #1448) but was really a missing route, so the test could never pass and failed with `invalid character '<'`. The probe now hits Grimmory's public `GET /api/v1/healthcheck` endpoint for reachability and version; credential verification stays with the separate login round-trip, so "Test connection" still reports whether your username/password actually work. - **Storage health now says *why*, not just *that*** (#1427) — the "downloads and library can't hardlink" banner and the "not writable" warnings were generic, sending operators hunting through mount tables and permission bits blind. The hardlink probe now names the actual cause (different filesystems; same device ID but cross-mount EXDEV, typical of mergerfs pools, separate bind mounts, and Unraid `/mnt/user` shares; or a filesystem that refuses hardlinks, common on exFAT/NTFS/network shares) in Settings → General. And a failed writability check now reports the uid/gid the process actually runs as versus who owns the directory, with the `user: "UID:GID"` hint when they differ — the classic case being folders prepared for the stack's usual user while the container runs as the distroless default `65532` because `user:` was never set in Compose. - **VPN-killswitch timeouts are named, not just reported** (#1474) — when an ABS or Calibre-plugin "Test connection" times out against a LAN-shaped host (private IP, bare Docker hostname, `.local`/`.lan` suffix), the error now points at the usual culprit: Bindery sharing a VPN container's network (`network_mode: service:gluetun`) whose killswitch drops LAN traffic, with the `FIREWALL_OUTBOUND_SUBNETS` fix named inline. Real upstream errors (auth failures, refused connections) and public hosts keep their unmodified message. Complements the "Running Bindery behind a VPN" deployment docs. - **Go race CI no longer times out** (#1531) — split the race suite into six parallel shards so the large API and database test packages stay below their per-package deadlines. Community PR from @magrhino. ### Docs - **Shared ebook + audiobook folder layout, and what `BINDERY_DOWNLOAD_DIR` actually does** (#1426) — the Storyteller-style shared folder (an ebook and its audio files in one directory) is Bindery's *default* behaviour when `BINDERY_AUDIOBOOK_DIR` is left unset, since both naming templates share the `{Author}/{Title} ({Year})` prefix; now documented with the resulting tree. And `BINDERY_DOWNLOAD_DIR` is **not** a watch folder — per-job import paths come from the download client's API, and the env var only feeds validation, storage health, the hardlink probe, and qBittorrent save paths — so the TRaSH split-tree layout (`/data/torrents` + `/data/usenet`) works as-is. ## [v1.25.0] — 2026-07-10 A contributor-driven release. The headline is manual metadata editing with field locks — edit a book's title, genres, language, description, or release date and every refresh path keeps your values — which also unlocks clean opinionated genre taxonomies in folder paths. Around it: naming-template conditionals and zero-pad widths, a `{Lang}` token, an ebook/audiobook/both selector on Add Book, per-account Hardcover reading lists, an honest Import Mode default (community PR), multi-user tenancy fixes for owner stamping and the series views, and the next batch of audit bug fixes. ### Added - **Manual metadata editing with field locks** (#1237, #1446) — the book page gains an Edit action for title, description, genres, language, and release date. Every field you edit is locked so the nightly metadata refresh, author-works sync, and ABS/Calibre re-imports keep your values (a 🔒 marks locked fields; one click unlocks them all). Genres can also be applied in bulk: an author-level override in Edit Author and a Set genre action per series stamp + lock the same genre list across all their books, so an opinionated genre taxonomy in folder paths (`{Genre}/...`) stays clean. Re-bind and metadata re-map clear locks — an explicit identity change asks for the new record wholesale. See the new [Metadata Editing wiki page](docs/Metadata-Editing-Wiki.md). - **Conditional text and zero-pad widths in naming templates** (#1127) — literal text placed inside a brace group renders only when its token has a value (`{Title}{ - Series}` emits the dash only for series books, no more trailing separators), and numeric tokens accept a width modifier (`{SeriesNumber:2}` → `02`) so alphabetic filename sorts keep parts in order. Both are backward compatible: bare tokens, `{Genre:Unsorted}`-style defaults, and unknown-token passthrough behave exactly as before. One edge: a 1–2 digit modifier is now a width, so a numeric *default* that short is no longer supported (3+ digits, e.g. `{Year:2024}`, still works as a default). - **`{Lang}` naming token** (#1175) — naming templates can now include the book's language code (e.g. `en`), so foreign-language books can carry the language in their folder or file name. Blank when the language is unknown, collapsing cleanly like the other optional tokens. - **Format selector on Add Book** (#1397) — the Add Book modal (Authors/Home page) now has an ebook / audiobook / both selector, matching the Series page. Default keeps the previous behaviour (provider metadata, falling back to the `default.media_type` setting). Picking a format applies it to the added book even when it already existed in the library, re-evaluating wanted status so the missing format gets searched. ### Security - **Multi-user tenancy: owners are now stamped and series views scoped** (#1457, #1416) — new books inherit their author's owner on every create path (add-book, author sync, series fill, ABS/Calibre imports), and new downloads carry the grabbing user (background grabs inherit the book's owner), so per-user scoping finally has data to scope on: a non-admin's queue is no longer empty under tenancy, and the series list/detail no longer let one user enumerate another user's titles, covers, and statuses. The author page's embedded book list now applies the same owner predicate as the book list, so the two views agree on co-authored books. Existing unowned rows keep their legacy world-visible behaviour; only affects deployments with `BINDERY_ENFORCE_TENANCY` enabled. ### Fixed - **Import Mode UI no longer claims Move is the default** (#1444) — the selector pre-selected Move while the backend has defaulted to auto (hardlink on the same filesystem, else copy) since the seeding fix. Auto is now a first-class, selectable mode shown as the default, restoring a UI path back to the safe behaviour, and `import.mode` is validated so a typo fails loudly instead of silently behaving as auto. Contributed by @johnistheman. - **Per-account Hardcover reading lists** (#1489) — Hardcover's built-in shelves ("Want to Read", …) share one slug per account, so loading a second person's lists with their token showed their shelf as the already-added first one and toggled the wrong list. List identity is now (slug, account): the picker reports which Hardcover account it's browsing, saved lists remember the account they came from (shown as an @username chip), and two households' "Want to Read" lists sync side by side, each with its own token. - **Lists no longer silently truncate at 100 rows** (#1467) — the History page now pages through your full history on the server (with a working event-type filter that offers all event types), an author's page loads the complete catalogue even for authors with more than 100 books (so counts, filters, and select-all cover everything), and the Calendar loads every release in the month instead of stopping at 500. - **Transmission/qBittorrent: healthy downloads no longer blocked as "source no longer available"** (#1461) — the stale-failure check treated a category-filtered torrent listing as a complete one. A failed-import download whose torrent was moved to another download directory, lost its label, or sat under a different qBittorrent category (while the unfiltered listing was unavailable) was terminally blocked even though the torrent was still seeding. Filtered or degraded listings now only block on retry exhaustion, never on "missing from the list". - **Library scan single-flight** (#1460) — triggering a manual library scan while another scan is running (manual or the scheduled one) now returns 409 Conflict instead of starting a second concurrent walk that could race on book creation and clobber the last-scan status. - **Migration runner semicolon gotcha** (#1465) — the SQL migration runner split statements on `;` before stripping comments, so a semicolon inside a `--` comment (or a string literal) corrupted the statement list and aborted boot. The splitter is now aware of line comments, block comments, and quoted literals, so migration authors can write natural SQL comments. ## [v1.24.3] — 2026-07-10 A small security-driven patch. The Go toolchain moves to 1.26.5 to pick up two stdlib vulnerability fixes that are reachable from Bindery code paths, and the Grimmory connection test now says what it actually received when a server (or a reverse proxy in front of it) answers with something other than JSON. ### Security - **Go toolchain bumped to 1.26.5** (#1492) — rebuilds all binaries and images against a stdlib carrying the fixes for GO-2026-4970 (`crypto/tls`) and GO-2026-5856 (`os.Root`), both confirmed reachable in Bindery by `govulncheck`. v1.24.2 binaries were built with the affected stdlib; no Bindery code changes were needed. Workflow `go-version` pins and the main `Dockerfile` digest move together so CI and the published image agree on the toolchain. ### Fixed - **Grimmory connection errors now name the content type** (#1493) — adding a Grimmory connection whose URL answers 2xx with a non-JSON body (typically a reverse proxy or SPA fallback serving an HTML page on the API path) failed with the bare JSON decoder error `invalid character '<' looking for beginning of value`. The client now reports the HTTP status and `Content-Type` it got back and points at the URL as the likely culprit, so misrouted proxies are diagnosable from the Settings screen. Refs #1485. ## [v1.24.2] — 2026-07-07 A fast follow-up patch. The headline fix is Bulk Folder Import, which stalled for minutes and then failed with no logs on any library with a few hundred entries, because it reloaded the whole book and author catalogue once for every item in the folder. The rest is a batch of correctness and security fixes surfaced by an internal audit: two data-safety races on the download pipeline, a nightly refresh that wiped enriched author metadata, a Transmission queue mapping that failed healthy downloads, and two hardening fixes covering system logs and indexer keys. ### Security - **System logs are now admin-only** (#1451) — `/system/logs` and `/system/loglevel` were reachable by any authenticated user, exposing the app-wide log stream (other users' book and author names, OIDC usernames, download titles) and letting a non-admin flip the global log level. They now require admin, matching every other global-infra route. - **Indexer API keys no longer leak into errors** (#1450) — when a torrent/NZB fetch failed at the transport layer (timeout, DNS, TLS), the underlying `url.Error` carried the full signed download URL, including the indexer's `apikey`, into the download row, history, and webhook/Discord notifications. All five download-client fetch paths now scrub the key before the error is wrapped. ### Fixed - **Bulk Folder Import no longer times out on large libraries** (#1473) — scanning a folder used to reload the entire book and author catalogue once for every item, so a folder with a few hundred entries fired hundreds of full-table queries in a single request and ran past the server's request timeout, leaving the connection dead and no logs to explain it. The scan now loads the catalogue once and reuses it for every item, and it logs when a scan starts and finishes with how long it took and how many matches it found. - **Download status races** (#1462) — two things updating the same download at once could slip an illegal state change through (re-completing an already imported download or double stamping timestamps). The status update is now applied atomically so only one writer wins and the row can't land in a bad state. - **Transmission downloads queued behind others no longer fail to import** (#1452) — the RPC status enum was mapped wrong (`3` was treated as "seeding", `6` as "stopped"; they are actually "queued to download" and "seeding"). A torrent sitting in Transmission's download queue at 0% was treated as complete, import fired against an empty directory, failed, and after exhausting its retries the download was terminally blocked even though it was perfectly healthy. Completion is now keyed off real seeding / 100%-downloaded state. - **The wanted-book sweep no longer double-grabs** (#1453) — a Wanted book stays Wanted until its file is imported and reconciled, so a book whose grab was still downloading (or working through the import pipeline) was re-searched on the next scheduled sweep and, if the indexer now ranked a different release best, a second download was grabbed for the same book. The sweep now skips books with a grab already in flight, while still re-searching books whose previous download failed. - **Author metadata refresh no longer wipes enriched fields** (#1463) — the nightly refresh now keeps the existing description, ratings and rating count when the upstream record comes back sparse, instead of overwriting them with empty values. - **qBittorrent category rejections are no longer ignored** (#1464) — Bindery now notices when qBittorrent rejects a category assignment (for example a 409 because the category does not exist) instead of silently ignoring it, so the missing-category warning fires and a mis-categorized torrent no longer vanishes from the download poll. - **Grimmory "Test connection" now authenticates before probing status** (#1448) — recent Grimmory guards `/api/status` behind a valid session, so the test button returned a 401 for anyone using username/password auth. Bindery now logs in first and presents the token when checking connectivity (retrying once if a cached token has expired), so the test succeeds instead of failing at the door. - **Log search takes wildcards literally** (#1466) — searching the logs for a term containing `%`, `_`, or `\` now matches those characters as text instead of treating them as SQL wildcards, so you get the results you expect. - **Faster library, book, and search queries** (#1454) — the query that finds each book's ebook/audiobook file ran twice per book listing and was doing a full scan of the `book_files` table because no index covered its `format` filter. A new composite index makes it an index seek, most noticeable on large libraries and on the paginated Books page. Applied automatically on upgrade. - **Bulk "search" on the Wanted page is much faster** (#1455) — selecting a large batch and hitting search issued one of the heaviest book queries per book instead of a single batched fetch, so a 500-book bulk search ran hundreds of full-table aggregations. It now loads all selected books in one query. ## [v1.24.1] — 2026-07-06 A patch cut of fixes surfaced by the community in the days after v1.24.0: two were Bindery giving actively wrong information (a dead-end fix for NZBFinder's error 203, and Grimmory test failures rendering as an opaque "Bad Gateway"), and two were the library scan and bulk import silently mishandling flat `Author/Title.epub` layouts. ### Fixed - **Error 203 guidance no longer points at a setting that can't be changed** (#1424) — the NZB grab error and the Troubleshooting wiki suggested disabling Prowlarr's per-indexer Redirect setting, which Prowlarr requires for Usenet indexers. Both now explain the real situation: application-whitelisting indexers (like NZBFinder) have to approve Bindery's identity, tracked in #1425. - **Grimmory "Test Connection" failures now show the real error** (#1431) — a failed test displayed the bare HTTP status text ("Bad Gateway") instead of the actual diagnostic (connection refused, login rejected, upstream proxy error). The UI now surfaces the full message, upstream HTTP errors are labeled with their status code, and failures are logged server-side. - **Bulk folder import shows the full source path on every row** (#1435) — previously only the basename was shown, so an ambiguous match gave no way to tell which file the "pick a book" dropdown referred to. - **Library scan no longer hides untracked ebooks that share a folder with a tracked one** (#1436) — in a flat `Author/Title.epub` layout, one registered file made the scan silently skip every sibling ebook in that author folder, and the skipped files were missing from the result counts entirely. Folder-level suppression now only applies to audiobook folders, and the scan result shows an "Already tracked" count so files found always adds up. ## [v1.24.0] — 2026-07-04 The Grimmory integration goes from a settings tab nothing read to a working push pipeline, and the UI can now be embedded in a dashboard iframe when an operator explicitly opts in. A new per-author "Monitor newly discovered books" policy defuses the refresh-mass-monitors-the-back-catalogue trap. Three community bug reports filed this week are fixed in the same cut: books silently stranded under the wrong author, the author-page filters mishandling dual-format books, and unreadable NZB grab failures. Repo-side, the CI pipeline gains AI triage/review bots and a hardening pass on the fork-facing workflows. ### Added - **Grimmory push pipeline — BookDrop upload on import plus bulk sync** (#1392, closes #826) — the Grimmory integration was configuration-only: a `Ping()` client and a Settings toggle no code ever read. It now works end to end. A new `internal/grimmory` client does JWT auth against Grimmory's API (login → access/refresh pair, rotation, one 401 retry after re-auth; a set `api_key` is honoured as a static Bearer token and bypasses login), and streams a multipart upload to `POST /api/v1/files/upload/bookdrop` on a dedicated 5-minute timeout. A `Pusher` is hooked into the importer alongside the Calibre/CWA handoffs — settings are read live per push (no restart) and pushes are best-effort by contract, so a Grimmory failure never fails the import. BookDrop has no server-side dedup, so idempotency is Bindery's: migration 059 adds a `grimmory_pushes` table keyed by file path, consulted on every push. Bulk sync (`grimmory.Syncer`, admin-only `POST /grimmory/sync` + `GET /grimmory/sync/status`) mirrors the Calibre single-job pattern (409 on concurrent start, polled progress, capped error list). Settings → Grimmory gains username/password fields, a real Test Connection login check, a "Push all to Grimmory" button with live progress, and the experimental banner is gone. Ebook files only for now — BookDrop takes one file per upload, which multi-part audiobook folders don't reduce to. - **Opt-in iframe embedding via `BINDERY_FRAME_ANCESTORS`** (#1367) — the UI could not be embedded in an `