Six fixes. Three of them are one story: a book's media type records what Bindery
was told to go and fetch, but the book detail page was using it to decide what
to show you about the files already on disk. A book can legally hold both an
ebook and an audiobook, and when the two disagreed the page put an Audiobook
badge next to an epub's path, gave the second file no surface at all, and —
the part worth upgrading for — offered a Delete button that removed both
formats while naming one of them. The File section is now a list of what is
actually there, and every delete says exactly which paths it will remove.
The other three are things Bindery was doing repeatedly and silently: work it
had already done, requests it had already been refused, and a field it was
confidently reporting wrong.
The one most likely to be felt is the author refresh. Before queuing a search
for each book it creates, the sync checked whether the file was already on disk,
and that check walked the entire library — once per book. On local disk the OS
caches the directory tree and it hides; on a NAS mount it does not, and a
65-book refresh spent close to an hour doing nothing but re-reading the same
directories. It now walks each library root once per refresh.
The other two came from users noticing something odd and looking closer. An
indexer that answers "request limit reached, retry in 485 minutes" was being
asked again on the very next search, and every search for the following eight
hours, because nothing recorded what it had said. And a book whose file was
Spanish displayed as English forever, because the language shown came from the
metadata provider's description of the work and the file's own tag was only
ever read when the provider had supplied nothing.
Fixed
-
The book detail page now lists every file on the book, and a delete can no
longer remove a format you were not shown
(#1948) —media_type
records acquisition intent, what search and monitoring are told to hunt for.
book_filesrecords inventory, what is on disk. The File section rendered
inventory through the intent value, so any file outside the declared type was
invisible to display but still included in destruction. A book marked
audiobookthat also held an epub showed a 🎧 Audiobook badge (from the media
type) next to the epub's path (from the legacyfile_pathcolumn, which was
kept ebook-first regardless of media type); the audiobook itself had no row at
all, because the format switcher only appeared for books already marked
dual-format; Download sent no format and served the epub; and Delete file sent
a format-lessDELETE, which enumerates every registered file and removes
both formats, while the dialog named one path and described it as the other
format's. The badge and the path came from different sources, and the
confirmation and the request disagreed.The section is now a list built from the files themselves, grouped by format,
each group badged by its own format and nothing hidden behind the declared
type. Download and Delete live on the format group and are always scoped to
it, which is the honest unit: both endpoints act on every file of that format,
plus, for delete, the same-name sibling sweep. The confirmation dialog lists
every path the request will remove and is built from the same state the
request is, so the two cannot drift apart again. A format-less delete is still
available but only as an explicit Delete all files action, and its dialog
lists every path across both formats. Fix match now moves the file whose
row you opened rather than whichever format the switcher was on, and the
switcher is gone: hiding one format behind it is what made a registered file
invisible in the first place. One thing the switcher did carry is kept: a
format the book wants but has no file for still shows as Not downloaded,
so a dual-format book with one file on hand says which half it is still
waiting on. Books that predate thebook_filesmigration and were never
re-imported still render from the legacy columns, and because that legacy
single path carries no format of its own, its Download and Delete deal with
it as the book's only file rather than guessing a format the server might
disagree with.Two smaller things fell out of the same work. A new per-file Forget this
file action drops a stale path from Bindery's records without touching disk
— the database-only mode added in
#1692 had no interface at
all until now, which is what you want when a file has already been moved or
removed elsewhere and the old path is still being reported. And the media
badge was a two-way ebook/audiobook check, so a dual-format book displayed as
"📖 Ebook"; it now renders both. -
A book holding both formats now declares itself dual-format
(#1946) — the display fix
above makes the page correct whatever the media type says, but the media type
was also simply wrong, and it is what search and monitoring read. When both an
ebook and an audiobook are registered against a book, its media type is now
widened tobothon the next file event, because a file on disk settles the
question of what the book is. This is driven by inventory and is deliberately
independent of the metadata-driven widening pinned in
#1732: that pin exists
because Hardcover lists an audio edition for most popular titles, so widening
from metadata alone was widening on a claim. Here the audiobook is already
imported. Widening only ever fires when both files are present, so it cannot
flip a book back to wanted or start a download. Affected books heal on their
next import, delete, rename, or library reorganize; nothing runs at upgrade.Two related corrections ride along. The legacy
file_pathcolumn, which is
what the format-less download endpoint and OPDS serve, now prefers the path
matching the book's media type instead of always taking the ebook. And the
book list'smediaType=bothfilter now works: theebookandaudiobook
filters deliberately include dual-format books, so neither of them isolated
them, and the literal valuebothfell through unhandled and returned the
entire library. The Books page has a 📖🎧 Both button to match. -
Editing an unmonitored book no longer starts a download
(#1947) — the book update
endpoint fires an immediate indexer search whenever a book crosses into
wanted: a status edit, a "Delete file", or a media-type change that exposes
a format it does not have
(#1148). The only thing
guarding that was the global auto-grab kill-switch, so widening a book to
dual-format grabbed the missing format even when the book was explicitly
unmonitored — the one per-book control for "keep track of this, do not go and
get it". It now honoursmonitored. The status still changes and the book
still appears on the Wanted page; only the search is suppressed, and it runs
as normal once you monitor the book. The twelve-hour wanted scan already
honoured this, so nothing there changes. -
An author sync no longer walks the entire library once per new book
(#1888,
#1929) — before queuing a
search for each book it creates, the sync checks whether the file already
exists on disk, and that check did a full recursive walk of every library
root, per book. A sync that added 65 books walked the whole library 65 times.
On local disk the OS caches the directory tree and the cost hides; on an NFS
or SMB mount every walk is real network round trips per directory entry, and
at a few dozen seconds per walk this alone accounts for the reported
hour-long refresh. The sync now takes one snapshot of the library per
refresh: each root is walked once, on first use, and every per-book check is
answered from memory with the same matching rules as before — same root
selection per media type, same author-folder pre-filter, same title and
author comparison, in the same order. The walk also now honours cancellation,
which it previously ignored, so deleting an author mid-refresh stops the
filesystem work instead of letting it run to completion. One-off checks
(adding a single book, series add, recommendations) keep their per-call walk
and see the library exactly as it is at that moment; only files copied in by
hand while a refresh is mid-flight are invisible to that refresh's snapshot,
and the next refresh sees them. -
A rate-limited indexer is left alone until it says to come back
(#1934) — when an indexer
answers a search with a Newznab 500 (Request limit reached. Retry in 485 minutes.) Bindery used to record nothing, so the next search and every search
for the following eight hours sent it another request it had already refused.
The rate-limit classification existed but was consulted only inside a single
search, to stop the query cascade falling through to lower tiers. The retry
hint is now parsed out of the indexer's own message and that indexer is
skipped until the deadline passes, across the scheduled wanted scan, on-add
and bulk searches, and interactive search alike — they share one searcher, so
a limit hit by one is respected by all of them. An indexer that gives no hint
gets an hour; a parsed hint is clamped to between a minute and a day so a
malformed or absurd value cannot bench an indexer indefinitely. Editing the
indexer clears the hold immediately, so a new API key or a different account
takes effect on the next search rather than waiting out a lockout that
belonged to the old configuration. Interactive search reports the held indexer
as skipped with the deadline in the Search details panel, in the same place
the original error appeared, rather than dropping it from the list.Two related gaps are deliberately untouched here and tracked separately:
authentication failures (a suspended account, a revoked key) get no cooldown,
because they never heal on a timer and a user who fixes their key must see it
work immediately — those need visibility and a notification
(#1935) — and auto-grab
still decides from whatever indexers answered without recording that the
others were unreachable
(#1936). The cooldown is
held in memory, so a restart costs one refused request per indexer per search
before it re-learns the limit, which is exactly the old behaviour and never
worse. -
A book whose file is in a different language than the catalogue says now
gets corrected on import
(#1933) — the language on a
book page came from the metadata provider, which describes the abstract
work, not the file you actually hold. The embeddeddc:languagetag was
read only when the provider had supplied nothing at all, so a Spanish EPUB
imported against an English OpenLibrary record displayed "English"
indefinitely, with the release name buried in a history row the only hint
otherwise. The tag is now read on every EPUB import and, when it disagrees
with the stored value, the file wins: a work has editions in many languages,
but the file on disk is one specific edition and is the thing you open. The
correction is recorded as a Language Corrected history event showing both
codes, so "why does my English book read as Spanish" has an answer in the
place you would look for it rather than only in a log line.Precedence is user, then file, then provider. A language you set by hand locks
the field, and a locked field is left alone — the EPUB is not even opened.
Filling a language the catalogue never had is unchanged and stays silent: it
is a gap being filled, not a disagreement, and OpenLibrary routinely supplies
no work-level language at all. Comparison normalises both sides first, so a
provider'senagainst a file'sengis not mistaken for a conflict, and
nothing is written when an import fails — a book that never landed must not
rewrite its own catalogue entry.
Internal
- Race guard on the metadata test doubles
(#1924) —mockProvider's
call recorders were written from several goroutines once the author refresh
started fanning out, which the race detector caught intermittently. Test-only;
no runtime behaviour changes. - Base image digests bumped for the
telemetry-serveranddiscord-stats
helper containers (#1925,
#1926). The Bindery image
itself is unchanged.