Commit Graph
12 Commits
Author SHA1 Message Date
QuickandGitHub 10394b0a05 fix(scanner): stop hiding media when a library root is offline (#472)
* fix(scanner): stop hiding media when a library root is offline

A scan that cannot read a library root found no files there, so every
cataloged file under it was marked missing. Catalog reads all filter on
missing_since IS NULL, so marking is equivalent to deletion from a user's
point of view: the title leaves browse, search and next-up, and playback
answers "Source media file is missing" for media that is intact on disk.

The dead-root protection already existed but only guarded the destructive
operations. protectedConfiguredRoots was computed *after* the marking loop
in scanPaths, and applyScopedScan received the protected set but applied it
only to its force-delete branch. So an unreachable root could not lose its
rows, but could still have its entire catalog hidden until the next
successful scan.

On a CephFS deployment whose per-library subvolume mounts flap, this marked
190 present files missing in a single day — 15% of all missing-flagged rows
were files sitting untouched on disk, some flagged more than 20 hours after
their last write.

Hoist the probe above the marking loop and skip files under an unreachable
or suspect-empty root in both the folder and scoped paths. Pass the
unreachable set to the walked-scope call too: a nested child mount can die
under a healthy parent, and its rows are inside the parent's scope.

An offline root tells us nothing about whether its files exist. The only
safe reading is to leave them alone and let the next good scan decide.

Genuine deletions under a reachable root are unaffected and still marked
and swept on the same schedule as before.

Report the count as ScanResult.MissingSkippedProtected and log it, so an
operator can tell "my library shrank" from "my mount dropped".

Known gap: a suspect-empty *nested child* root under a healthy parent is
still marked missing, because the suspect set is not resolved until after
the walk loop. Unreachable roots — the case observed in production — are
covered.

* fix(scanner): protect suspect-empty and partially-walked roots too

Addresses review findings on #472. The original change guarded missing-marking
against probe-unreachable roots, but left three ways for a storage fault to
still hide a healthy library.

Suspect-empty detection was reactive. suspectEmptyRoots asked
ListRootsWithOnlyMissingFiles, which returns a root only once it has NO live
rows left. On the first scan after a mount drops — the moment that matters —
the rows are still live, so the root was not classified suspect and the scan
marked everything missing. The protection then engaged on the next scan, in
time to protect the wreckage. Ask ListRootsWithCatalogedFiles instead: any
cataloged row under an empty-but-reachable root is the lost-mount signature.
Intentional emptying is still reachable through the operator's one-time
cleanup allowance, which is the deliberate path for it.

Nested suspect-empty children were unprotected. Root compaction sends only the
populated parent through the walked-scope branch, which received only
unreachableRoots, so an empty child mountpoint had its rows marked missing on
its parent scanning cleanly. Pass the suspect set as well.

Partial walks were treated as authoritative. walkLogicalTree deliberately
swallows per-entry Lstat/ReadDir failures so one bad file cannot abort a scan
of a million, and collectLogicalFilePaths passed nil for the failure counter —
so the video path had no signal at all. A mount dying partway through
traversal produced a short file list indistinguishable from a large deletion.
Thread the counter through, and exclude a scope whose walk came back
incomplete from missing reconciliation, mirroring what the ebook scanner
already does via ebookRootScan.failed.

Also extract the duplicated mark-missing loop into markMissingExcludingProtected
so the folder and scoped paths cannot drift, and correct two comments that
still described the pre-fix "files are marked missing" behaviour — the exact
text a future reader would have trusted when reintroducing this bug.

TestScanFolderNestedSuspectEmptyChildRootProtection asserted the old
behaviour and is updated accordingly.

* fix(scanner): scope walk-failure protection and stop pruning on partial walks

Addresses the second Codex review round on #472. The previous commit's
incomplete-walk protection was too blunt in one direction and applied too late
in another.

Walk failures were counted, not located, and any non-zero count protected the
whole library root. A dangling symlink is both common and permanent, so that
would have suppressed missing-file reconciliation for its entire root on every
future scan — genuinely deleted titles would stay live indefinitely. That is
the same class of bug as the one this PR fixes, pointing the other way.
recordWalkFailure now records the logical path of each unreadable entry, and
only those paths are protected. Per-entry failures record the child path, so a
dangling symlink protects itself and nothing else, while a directory that
cannot be read protects its subtree.

Snapshot and group pruning ran before the protection. reconcileScannedRoots
and reconcileScannedGroups delete whatever the walk did not see, and both run
ahead of the missing-file guard, so a partial walk still dropped root
snapshots, observed locations and group locations for the unread portion —
corrupting later metadata matching even though the media_files rows survived.
Upserting what was seen is always safe; pruning now waits for a scan that read
the whole tree.

The confirmed-cleanup allowance was consumed to no effect for nested suspect
children. The walked-parent branch protected them unconditionally and runs
before the allowance is consumed, and an already-reconciled scope cannot be
revisited — so arming the allowance burned the confirmation while the child's
rows stayed live forever. Read the allowance without consuming it before the
walk loop, and honour it there. Unreachable roots stay protected either way:
an outage is never a confirmation to erase a catalog.

Two new regression tests, plus signature updates in the ebook pipeline, which
already tracked walk failures and now shares the path-based representation.

* fix(scanner): re-probe nested roots and gate group pruning on walk completeness

Third Codex review round on #472; both findings confirmed.

Group pruning ignored walk completeness in the subtree path. scanPaths passed
the completeness decision to reconcileScannedRoots but left
reconcileScannedGroups on !allowEmptyRootGuard, which is always true for
ScanSubtree — so a subtree scan that hit an unreadable directory still replaced
group snapshots and locations from a partial inventory. Same rule now applies
to both.

Nested roots were not re-probed before their parent was reconciled. Root
compaction folds a child mount into its parent for traversal, so a child that
is healthy at the initial probe but drops before the parent is walked leaves no
scope of its own, and the post-walk re-probe only revisits scopes that walked
empty. The parent walks files, looks healthy, and the child's rows are marked
missing on its success. reprobeNestedRoots re-checks this root's configured
children immediately before reconciling, protecting any that have since become
unreachable — or suspect-empty, unless the operator has confirmed cleanup.

Also guard suspectEmptyRoots against a nil file repository, matching
emptyCleanupArmed: without a catalog there is nothing to protect.

* fix(scanner): keep re-probed outages protected through folder-wide cleanup

Fourth Codex review round on #472; both findings confirmed. The first could
destroy data.

reprobeNestedRoots protected a root it found offline only for the scope being
reconciled, then discarded the result. The folder-wide membership reconcile and
the trash sweep afterwards rebuilt their protected set from the initial probe
alone, so rows under a child that dropped mid-scan — already marked missing and
past the removal grace — were hard-deleted by the very scan that noticed the
outage. Accumulate those roots in reprobedRoots, fold them into
protectedScanRoots, and reuse that set for the membership reconcile and sweep
instead of rebuilding. They now also land in ScanResult.UnreachableRoots so the
folder warning reflects the outage rather than presenting a partial scan as
clean.

Snapshot and group pruning was enabled for scopes that were never walked. The
gate was len(walkFailures) == 0, but an unreachable root gets nil walkRoots, so
it has no walk and therefore no failures — and pruning then deleted its
snapshots, observed locations and group locations even though its media rows
were protected. The same held for a suspect-empty child compacted into a
populated parent. Pruning now additionally requires that the scope was actually
walked and contains no protected path.

The new test pins that the sweep honours the protected set it is given. It does
not reproduce the mid-scan race itself: staging that needs the drop to land
between the probe and the walk, which a test cannot reach without hooks. That
path is covered by inspection, and the test comment says so rather than
implying coverage it does not have.

* fix(scanner): route every protection source through one folder-wide set

Fifth Codex review round on #472. Two P1s, one of them the second data-loss
path in this area — and the direct sibling of the one fixed in 35326adc, which
is the reason this commit changes the structure rather than patching another
edge.

Rows beneath a directory the walk could not read were protected only inside
applyScopedScan. The folder-wide protected set was rebuilt from the probe
results alone, so DeleteMissingByFolder could permanently delete rows past the
removal grace under a subtree this scan never managed to read — deleting on the
strength of an observation that was never made.

The recurring defect is structural: protection is discovered in several places
(initial probe, mid-loop re-probe, per-scope walk failures) and consumed in
several more (scoped reconcile, membership reconcile, trash sweep), and each
fix so far has wired up one edge and missed another. Every source now
accumulates folder-wide and every consumer reads the combined set, so a new
source has one place to register instead of several to remember.

reprobeNestedRoots classified from two probe batches. It called
probeUnreachableRoots, then suspectEmptyRoots probed the same paths again; a
child dropping between the samples was reachable to the first and discarded by
the second, which only returns reachable-and-empty roots. It now classifies
both states from one batch, so the disconnect it exists to catch cannot fall
between its own probes.

Re-probed roots kept their classification instead of being collapsed into
unreachableRoots, which had been reporting a suspect-empty child as
unreachable and giving operators contradictory failure information.

The new regression test is verified to fail with the propagation disabled and
pass with it, rather than assumed to cover the path.

Not addressed: the cleanup allowance is read without being reserved, so two
overlapping full scans of one folder can both observe it armed. Narrow, needs
a transactional reserve in the scan-claim query, and is left for follow-up
rather than bundled here.

* fix(scanner): resolve root protection before scoped metadata pruning

Sixth Codex review round on #472.

scanPaths pruned before it knew what was protected. reconcileScannedRoots and
reconcileScannedGroups ran roughly 160 lines ahead of protectedConfiguredRoots,
so a ScanSubtree of a mount that dropped but left a reachable empty mountpoint
walked clean, reported no failures, and pruned root snapshots and observed and
group locations against that empty inventory — preserving the media rows while
deleting the metadata describing them. Protection is now resolved before any
reconciliation, and both prunes share one decision, matching applyScopedScan.

Pending empty scopes never re-probed their nested children. A parent whose only
media lives in a child walks empty when that child drops, so it lands in
pendingEmptyScopes rather than the populated-scope branch where
reprobeNestedRoots ran. Probing the parent alone proves nothing: it still holds
the child's bare mountpoint directory, so it reads present and non-empty. With
a healthy sibling keeping the folder-wide empty guard quiet, nothing protected
the child. Both branches now re-probe.

MissingSkippedProtected never left the scanner. Both ingest-to-result
conversions copied every other cleanup count but not this one, and
events.ScanRunResult had no field, so scan history, completion events and API
responses reported an all-zero no-op for a scan that skipped files because
storage was offline. Added as a new field, which is additive under the v1 API
rules.

Test honesty: the new test does NOT exercise the pending-scope re-probe. It
empties the child before the scan, so the initial probe classifies it and
protection arrives by that path — verified by confirming the test still passes
with the re-probe disabled. It is named and commented for what it does cover.
The mid-scan race behind both re-probe fixes needs the drop to land between the
probe and the walk, which is not reachable from a test without hooks; those
fixes rest on inspection.
2026-07-25 14:47:12 -04:00
rxwatcherandClaude Fable 5 fcd7eb697d fix(ebooks): harden OPF sidecar ingestion
Close the symlink-swap window in the sidecar reader: os.Open follows
symlinks, so a leaf swapped between the Lstat gate and the open could pull
metadata from outside the library root. Reject unless the opened handle is
the exact file Lstat inspected, matching the image cache processor guard.

Replace a stale ISBN provider id on rescan instead of silently dropping it,
so sidecar ISBN corrections actually take effect; tolerate the ISBN already
belonging to another item so duplicate copies keep scanning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:43:57 +02:00
rxwatcher 44db85457c feat(ebooks): ingest external OPF metadata sidecars 2026-07-22 13:43:57 +02:00
rxwatcher b3e963f54e fix(scan): handle typed media file edge cases 2026-07-22 13:43:57 +02:00
rxwatcher f094d70996 fix(ebooks): model targeted scans as file-only 2026-07-22 13:43:57 +02:00
rxwatcher fcaee9bfce fix(ebooks): persist bounded reconciliation cursor 2026-07-22 13:43:57 +02:00
rxwatcher 9136803130 fix(ebooks): harden enrichment rollout controls 2026-07-22 13:43:57 +02:00
rxwatcher 1d5f19d794 fix(ebooks): keep scans resilient to queue errors 2026-07-22 13:43:57 +02:00
rxwatcher 1c2d422548 fix(ebooks): bound enrichment lease work 2026-07-22 13:43:57 +02:00
1664c60425 fix(metadata): publish artwork revisions atomically (#399)
* fix(metadata): publish artwork revisions atomically

* fix(metadata): harden artwork revision cleanup

* fix(metadata): address artwork revision review findings

- restore image applies for all media_items types and reject unsupported
  target/image combinations with 400 before uploading; episodes coerce to
  stills and the web dialog no longer offers image tabs episodes can't use
- add WHEN clauses to displacement triggers and hoist to_jsonb so bulk
  catalog upserts that assign unchanged artwork columns skip the trigger
- make artworkkey the single variant-ladder owner: imagecache derives its
  widths from it and triggers store image_type instead of hardcoded
  variant arrays, expanded by the collector at deletion time
- sweep dormant registry rows periodically so references lost through
  untriggered surfaces degrade to slow cleanup instead of leaking
- park just-published revisions dormant, keep dormant rows dormant on
  re-cache, and batch the GC reference pre-check per run
- heal rows re-referencing a just-deleted revision via reconciler-style
  resets after the deletion commits
- share a per-URL image-loaded hook across DetailHero, ItemCard,
  SectionItemCard, GlobalSearch, and CollectionPosterCard
- deduplicate Cache/CacheBytes finalization and drop unused VariantPaths
  plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): cast reused timestamp parameter in revision upsert

Postgres cannot deduce one type for $3 used both as a plain value and
inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every
publication. Cast both uses and cover the arm/park/track upserts with
database-backed tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address artwork revision review comments

- keep a durable heal path: deletion marks deleted_at instead of removing
  the registry row, so a failed post-delete heal retries with backoff and
  broken references never park; trackers clear the marker on re-upload
- never treat bare existence as an immutable-content match; backends
  without content verification rewrite the object
- exercise revisioned cover keys in scanner/enrichment fakes, compare the
  tracked manifest exactly, and honor cancellation in the blocking test
  deleter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:21 -04:00
97ac2b4eed feat(audiobooks): audiobookshelf support — ABS conformance, perf, ebooks (#289)
* fix(ebooks): fold author hint into metadata search query

The ebook enricher loaded each item's author but buildEbookSearchQuery
dropped it, and metadata.SearchQuery had no field to carry it — so the
plugin only ever received the title. Title-only searches collide or miss,
leaving items without metadata or a cover.

Add SearchQuery.Author and fold it into the plugin search query text
(the SearchMetadataRequest contract carries a single free-text Query, so
no proto change is needed). Gated to callers that set Author (ebooks);
movie/TV search is unchanged.

Verified live against OpenLibrary/GoogleBooks: improves disambiguation on
clean titles. Note: messy filename-derived titles (series prefixes,
trailing "(… Book N)") still need title normalization, and a large tail
of niche/self-published ebooks is simply absent from the free sources —
neither is addressed here.

AI-use disclosure: authored with Claude Code.

(cherry picked from commit ba1265909c4fb87e1a8eab64b0b0c183aa95acc1)

* feat(scanner): extract MOBI/AZW/AZW3 metadata from EXTH headers

These formats previously had no parser — parseEbookFile returned only the
format string, so title fell back to the filename with no author and no
ISBN, leaving ~21k books unmatchable by the metadata enricher.

Parse the Palm Database container (PDB header → record 0 → PalmDOC +
MOBI header → EXTH block) and extract title, authors, ISBN, publisher,
and language. EXTH is located by its magic rather than the header flag,
and field offsets (encoding @12, full-name @0x44/0x48) were verified
against real .mobi/.azw3 files.

Verified live against real library files:
  azw3 → title "The Sea", author "A H Lee"
  mobi → title "Brotherband 3: The Hunters", author "John Flanagan",
         ISBN 9781742750637

AI-use disclosure: authored with Claude Code.

(cherry picked from commit 7af194b711de97bc79855f08a9a4f9732c49db74)

* fix(ebooks): recover author from path and clean provider search title

- ebookAuthorFromPath: recover an author for ".../<Author>/<Title>/<Title> -
  <Author>.ext" layouts when the file embeds none, gated on two agreeing
  path signals (grandparent dir == filename suffix) so magazines/courses
  never get a junk author; strip the suffix from a path-derived title.
- cleanEbookSearchTitle: normalize filesystem-mangled titles before search
  (underscore->space, drop trailing " - <author>") to lift hit rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 36a16cb58c3e5276aa4c0bdf8577008070f6abea)

* fix(scanner): gate path-author on person-name shape

ebookAuthorFromPath's grandparent==suffix corroboration also matched
inverted layouts ("<Title>/<Author>/<Author> - <Title>"), assigning the
title as the author. Require the candidate directory to look like a person
name (comma form, or all-capitalized tokens plus name particles) so series
and title folders ("De legenden van de Alfen") are rejected, and return the
canonical directory form for proper casing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit ff720bd268a23bff0e94c70f15cb7ecfb8efcb1f)

* fix(ebooks): strip series/book-number parentheticals from search title

cleanEbookSearchTitle now peels trailing "(... Book N)", "[#3]", "(2019)"
groups that don't belong in a provider title query, while leaving
meaningful parentheticals ("(Illustrated)") intact. Enrichment-side only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 2273636c04fd9ef483003a9558972a2104fdd3a6)

* fix(ebooks): keep volume number in search title and dedup provider IDs

Two distinct ebooks (e.g. series volumes named only by series + book
number) were collapsing onto a single provider work, then fighting over
the same media_item_provider_ids row:

- cleanEbookSearchTitle stripped trailing "(... Book N)" / "[#3]" groups
  entirely, so every volume of a series searched as the bare series name
  and matched the same provider work. The plugin search contract carries
  only a single free-text Query, so the volume number is now UNWRAPPED
  into the query (brackets dropped, words kept) instead of discarded,
  giving distinct volumes distinct searches. Bare-year groups are still
  dropped (SearchQuery.Year carries them); meaningful parentheticals
  ("(Illustrated)") still survive.

- collectEbookMetadata now consults FindContentIDByProviderIDs before
  accumulating a search-result provider ID. An ID already owned by a
  different content item is skipped, so the loser is not mis-tagged with
  the winner's metadata and ReplaceByContentID no longer violates the
  (provider, provider_id, item_type) unique constraint. The previous
  behavior logged duplicate-key errors every sweep and re-enriched the
  failing item forever (CPU/RAM churn). A failed ownership check is
  surfaced as a provider error so the item retries rather than terminally
  stamping as "no match".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 942fdef6cb0e167b2d9223e9a968b3010b7b3ec8)

* fix(ebooks): address CodeRabbit review on PR #185

- cleanEbookSearchTitle: anchor author-suffix strip to a trailing match
  (optionally followed by a series/volume parenthetical) so a mid-title
  " - <token>" no longer truncates valid title text
- ebook scan: strip the recovered author suffix using normalized comparison
  so case/spacing variants (e.g. "a. f.  carter") don't leave a duplicate
- parseMOBIEXTH: bound parsing to the declared EXTH length so a corrupt
  record count can't read full-text bytes as junk metadata
- add regression test for a non-trailing " - <token>" in the title

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0f45af8143e04dfd5b4a5ee3e949dcd943eedbd1)

* fix(audiobooks): pass author in search query and retry on provider errors

Set SearchQuery.Author so the host adapter folds author into the
plugin free-text query (parity with ebooks). Track provider errors
during enrichment; when nothing matched and a provider errored, return
an error without stamping last_refreshed so the sweep retries instead
of terminally burning the item on a transient failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f45dd2104c5d6415324e80f91f6684bc39858459)

* fix(scanner): consolidate fragmented multi-file audiobook content_ids on rescan

audiobookFolderShouldSkip used ListByObservedRootPath which returns all
files for a root path regardless of content_id. When a multi-file audiobook
had files fragmented across multiple content_ids (e.g. from concurrent
refreshes), the file count matched disk so the skip check returned true
and the reconcile never ran to merge them.

Now verifies all DB files share the same content_id before skipping; any
fragmentation forces a full reconcile which consolidates to one content_id
via FindContentIDByRootPath → upsertAudiobookMediaFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit e91c33e88a7d09e802e6afd8af246c6c954d0498)

* fix(ingest): skip concurrent match drainer for audiobook/podcast/ebook/manga libraries

The concurrent scoped match drainer ran during scan for all library types.
For audiobook libraries, the scanner assigns content_ids by folder root
(one item per multi-file folder). Running the drainer concurrently caused
it to process files with content_id=NULL (cleared by complete refresh)
as individual items, creating one media_item per file instead of one per
folder. This manifested as 41-file audiobooks fragmenting into dozens of
orphaned single-file content_ids on every refresh.

These library types use scanner-driven grouping; the post-scan drain step
handles them correctly. Returning nil matchScopes skips the concurrent
drainer entirely for these types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 93ae9d22ce315874fa22a958b88ca1766075695f)

* fix(abs): match real audiobookshelf auth + session-sync contract

Align the ABS-compat auth flow with real audiobookshelf (v2.26+) so
third-party clients (yaabsa, Plappa, native iOS) authenticate and sync
playback correctly:

- login/refresh: always emit user.accessToken; x-return-tokens gates
  only the refresh token (body vs HttpOnly refresh_token cookie)
- /auth/refresh returns the full login envelope (was a thin token map)
- /me returns the full user object (toOldJSONForBrowser), shared with
  login/authorize via a single absUserObject() builder
- /logout returns 200 {redirect_url:null} and clears the cookie (was 204)
- add POST /session/{sid}/sync (real ABS heartbeat path); it was
  PATCH-only, so the official client's sync POST 404'd and playback
  progress never synced

Verified against advplyr/audiobookshelf server/{Auth.js,models/User.js,
controllers,routers}. Unit tests updated/added; full abs suite green.
Not yet live-verified.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 336e932471d4be021d82299106a120611783836a)

* fix(abs): conform browse/list items to real audiobookshelf minified shape

Strict ABS clients (yaabsa, Plappa) crash or drop items when the browse
list shape only approximates real audiobookshelf. Match the serializers:

- add media.id + media.libraryItemId (= ContentID) to LibraryItemMedia;
  yaabsa BookMedia.id is required non-null and was missing → the whole
  item failed to parse ("Null is not a subtype of String")
- rebuild the minified list shape to LibraryItem.toOldJSONMinified +
  Book.toOldJSONMinified + oldMetadataToJSONMinified key-for-key (ino,
  path, isFile, numFiles/size, media.{id,tags,numTracks,numAudioFiles,
  numChapters,size,ebookFormat}, flat author/series metadata)
- force media.numTracks/numAudioFiles >= 1 in the browse projection so
  Plappa doesn't drop items reporting 0 audio files
- default /items list to minified (real ABS list is always minified);
  minified=0 opts into the full shape

Verified against advplyr/audiobookshelf models/{Book,LibraryItem}.js.
Adds minified_test.go key-set conformance guards; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6c9387a8c4b60be3dbe541049ed8c06344b10717)

* fix(abs): conform /items/{id} detail to real audiobookshelf expanded shape

Match real audiobookshelf LibraryItem.toOldJSONExpanded +
Book.toOldJSONExpanded + oldMetadataToJSONExpanded so strict clients
decode the item-detail page with the same model they use elsewhere:

- add expanded outer keys to LibraryItem (oldLibraryItemId, lastScan,
  scanVersion, libraryFiles, size) and populate libraryFiles + summed
  size from the item's media files in the detail builder
- add media.size (Book.toOldJSONExpanded)
- make the typed Metadata the full expanded superset: subtitle,
  titleIgnorePrefix, authorName, authorNameLF, narratorName, seriesName,
  descriptionPlain, publishedDate, asin, language, abridged; drop the
  omitempty that previously dropped description/publishedYear/isbn/
  publisher when empty (a missing key crashes strict clients)

Verified against advplyr/audiobookshelf models/Book.js + LibraryItem.js.
Adds items_detail_test.go expanded key-set guard; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8bd485f0291e9db9f32e13a68609a68ee8a945ec)

* fix(abs): conform authors/series endpoints to real audiobookshelf shapes

Match the real audiobookshelf serializers so strict clients decode the
authors/series browse + detail responses:

- GET /libraries/{id}/authors now branches like LibraryController.getAuthors:
  bare { authors: [...] } when not paginated, paged { results, total, ... }
  only when limit+page are present (was always paged → clients keying on
  `authors` got keyNotFound)
- author objects carry the full Author.toOldJSON key set (id, asin, name,
  description, imagePath, libraryId, addedAt, updatedAt, numBooks); silo has
  no analog for asin/description/imagePath/timestamps so they are null/0
- series objects carry the full Series.toOldJSON key set (adds
  nameIgnorePrefix, description, libraryId, addedAt, updatedAt)
- series/author books are now FULL minified library items (real ABS shape)
  instead of thin {id,media:{metadata:{title}}} stubs that crash Plappa;
  author items moved to the real-ABS `libraryItems` key

Verified against advplyr/audiobookshelf controllers/LibraryController.js and
models/{Author,Series}.js. Tests updated + envelope-branch guard added; abs
suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8a22eb0900ed881d500ded315a508e1a07da14f3)

* fix(abs): add libraryId to collection/playlist objects (real ABS shape)

Real audiobookshelf Collection.toOldJSON and Playlist.toOldJSON both carry
a libraryId; silo's emitters omitted it, so a strict client modeling the
object with a required libraryId crashed. silo collections/playlists are
cross-library user-personal, so emit the virtual audiobook library id.

The books[]/items[] entries already carry the full LibraryItem shape and
inherit the browse-conformance fixes (media.id etc.). Envelopes were
already correct (paged for library-scoped, {collections}/{playlists} for
global).

Verified against advplyr/audiobookshelf models/{Collection,Playlist}.js.
Envelope key-set tests updated; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f7d2ff0565f05c3a6ef7f36f2b1f252bd373fa7a)

* fix(abs): conform library object + /libraries/{id} to real audiobookshelf

The library object was only {id,name,mediaType}; real audiobookshelf
Library.toOldJSON has 12 keys, so a strict client decoding the library
model crashed on the missing ones. Also GET /libraries/{id} always wrapped
the object in { library: ... }, but real ABS returns it directly unless
?include=filterdata is requested.

- audiobookLibraryMap now emits the full Library.toOldJSON shape (folders[]
  as LibraryFolder.toOldJSON, displayOrder, icon, provider, settings,
  lastScan, lastScanVersion, createdAt, lastUpdate). This also enriches the
  libraries[] on the login envelope, which shares the builder.
- handleLibraryDetail returns the library object DIRECTLY without include,
  and wraps in { filterdata, issues, numUserPlaylists,
  customMetadataProviders, library } (adds the missing
  customMetadataProviders) with include=filterdata.

GET /libraries already returned { libraries: [...] } (correct). Verified
against advplyr/audiobookshelf models/Library.js +
controllers/LibraryController.js. Adds libraries_shape_test.go; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit d05a2f2af1caf21a4ad04577a4787ba02cff091c)

* fix(abs): conform personalized recent-series shelf to real ABS series shape

The /libraries/{id}/personalized "Recent Series" shelf emitted thin
{id,name,numBooks,libraryId,books:[]} entities with an always-empty cover
stack. Emit the full real-ABS series object (seriesObjectABS, adds
nameIgnorePrefix/description/addedAt/updatedAt) with minified book items
(seriesBookMinified) — the same shape as /libraries/{id}/series so the
shelf card decodes identically and shows real covers.

Book shelves already used full minified items; the shelves array is a bare
array (matches real ABS getUserPersonalizedShelves). abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7c586f8c923cbc481f6f94e304af48dee379a999)

* fix(abs): conform listening-sessions to real audiobookshelf PlaybackSession shape

silo's /me/listening-sessions returned a thin 5-field session object
(id, libraryItemId, userId, timeListening, currentTime) wrapped in the
generic pagedEnvelope shape ({results,sortBy,filterBy,minified}). Real
audiobookshelf clients (Flutter/Swift strict decoders) expect the
MeController.getListeningSessions envelope
({total,numPages,page,itemsPerPage,sessions}) and each session to carry
the full PlaybackSession.toJSON() key set, so the missing keys (notably
mediaType, mediaMetadata, displayTitle, displayAuthor, coverPath,
duration, chapters, deviceInfo, playMethod, mediaPlayer, serverVersion,
date, dayOfWeek, startTime, startedAt, updatedAt, libraryId, bookId,
episodeId) crashed with keyNotFound errors.

Both handleListeningSessions and handleListeningSessionDetail now build
the response via a shared sessionToABS() that reuses
buildSiloPlayMediaMetadata (already used by /play) to hydrate
mediaMetadata/displayTitle/displayAuthor from MediaStore, batching
lookups via GetAudiobooksByIDs for the list endpoint. Lookups are
best-effort: a missing/inaccessible item falls back to a stub
MediaItem so every key is still emitted, never a crash.

Verified against advplyr/audiobookshelf server/controllers/MeController.js
(getListeningSessions) and server/objects/PlaybackSession.js (toJSON())
on GitHub master.

Known placeholders (real ABS fields we can't populate without extra
cost): chapters (empty array — would require a per-session media-files
fetch), duration (0 — total book duration isn't tracked on the session
row), startTime (0 — not persisted separately from currentTime),
deviceInfo (static "unknown" device, matching the /play endpoint's
existing placeholder — no device info is persisted per session).

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9471497c99b96c8d3defc6c8112c913f7c55924b)

* feat(abs): add offline session sync endpoints (/session/local, /session/local-all)

The official ABS mobile app records playback while offline and POSTs those
PlaybackSession objects back on reconnect via SessionController.syncLocal and
syncLocalSessions. silo was missing both endpoints, so offline listening
progress was silently lost. Add them to the bearerAuth-protected session group
(both /abs/api and /api prefixes) alongside /session/{sid}/sync and /close.

POST /session/local decodes one PlaybackSession and updates the caller's resume
position via ProgressStore.UpdateProgressPosition (the same call handleSessionSync
uses), emitting user_item_progress_updated. POST /session/local-all decodes
{sessions:[...]} and loops each robustly — a malformed or unknown item marks that
one result failed without sinking the batch — returning {results:[...]}. No new
store persistence or migration; podcast/episode sessions are accepted as no-ops.

Verified against advplyr/audiobookshelf server/controllers/SessionController.js
and server/managers/PlaybackSessionManager.js.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 008a4df948d855a4bfe62b24f89bfc484f088033)

* fix(abs): conform library search + items-in-progress to real audiobookshelf

Real ABS's libraryItemsBookFilters.search() (delegated from
LibraryController.search) returns { book, narrators, tags, genres,
series, authors } with no "podcast" key for a book library, and each
book entry is only { libraryItem } — no matchKey/matchText, which our
handler was inventing. Search now matches those keys, drops the
fabricated matchKey/matchText fields, and best-effort populates
authors/series buckets via client-side substring filtering over the
existing aggregate listers (narrators/tags/genres stay empty-but-present
since silo has no backing aggregation query for them yet).

MeController.getAllLibraryItemsInProgress wraps items as
{ ...libraryItem.toOldJSONMinified(), progressLastUpdate }; our handler
was emitting a hand-rolled subset of fields plus a nested
userMediaProgress object that doesn't exist in the real response.
items-in-progress now reuses the existing Minify() projection and merges
a flat progressLastUpdate (ms) field to match.

Verified against advplyr/audiobookshelf controllers/{Library,Me}Controller.js
and server/utils/queries/{libraryItemsBookFilters,authorFilters}.js.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 998ff55f3cf27d504f0e5aec8c7c29fa57f10247)

* fix(abs): /ping returns success:true and /status carries authMethods

The ABS apps validate a server address by reading response.success from
GET /ping; silo returned {pong:true,...} with no `success`, so the app
reported "unable to reach" even though the server responded 200. Also
/status was missing authMethods/authFormData, which the app reads to render
the login form.

- /ping now includes {"success": true} (pong/server/version kept as extras)
- /status now returns {app,serverVersion,isInit,language,authMethods,
  authFormData} matching real audiobookshelf Server.js

Verified against advplyr/audiobookshelf server/Server.js. Adds
ping_status_test.go; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit df732d09355303608bc4d5fc2138555e37497a02)

* fix(abs): mount login + auth/refresh under /api prefix

Clients that post to /api/login (and /api/auth/refresh) got a 404 because
login/refresh were only mounted at root and /abs/api — while the rest of the
authenticated ABS surface (/api/me, /api/authorize, /api/libraries, covers)
is served under both /api and /abs/api. The 404 surfaced in the client as a
generic "unknown error occurred" on sign-in.

Mount /login and /auth/refresh under all three prefixes ("", /api, /abs/api),
matching the authenticated groups.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 18071e180b02131cda354303eadd1ff3a0708065)

* fix(abs): accept form-encoded login bodies (not just JSON)

Real audiobookshelf (express body-parser + passport local) accepts both
application/json and application/x-www-form-urlencoded credential bodies.
Silo only json-decoded the body, so a form-encoded client got 400 "invalid
request body" — surfaced in the app as a generic "unknown error" on sign-in
(confirmed live: JSON creds -> 200, identical form-encoded creds -> 400).

Buffer the body once, try JSON, then fall back to url.ParseQuery for the
form-encoded case.

Adds login_body_test.go (form + JSON both reach the validator). abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 408dc33debb7a7ee363778094511ccfdd1ee70d2)

* fix(abs): emit full real-ABS serverSettings (OpenID/auth fields)

silo's login/authorize serverSettings omitted the auth + OpenID fields that
real audiobookshelf ServerSettings.toJSONForBrowser includes
(authLoginCustomMessage, authOpenID*, rateLimitLogin*, backupPath,
allowedOrigins). OIDC-aware strict clients (Prologue, iOS/Swift) decode
serverSettings into a model that requires those keys, so their absence throws
keyNotFound and the ENTIRE login response fails to decode — the client stays
on the login screen with a generic "unknown error" even though the server
returned 200. Simpler clients that don't model OpenID were unaffected.

Emit real ABS's OIDC-disabled defaults; authActiveAuthMethods still advertises
only "local" so no client initiates the OpenID flow.

Diagnosed from a packet capture (Prologue posts /login? with X-Return-Tokens
and gets a 200 it can't decode) + real ABS ServerSettings.js. Verified against
advplyr/audiobookshelf.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 283826c952824e97233e46e057f37385ddc3054b)

* fix(abs): GET /me returns the real display username, not the userID

/me built its user object from the token claims and passed the numeric
userID as the username, so clients saw "98" instead of "puksthepirate".
Login gets the display name from the credential validator, but /me only has
the token, so it needs a lookup.

Add an optional UsernameResolver to the abs Dependencies; wire it from the
concrete SiloCredValidator (which holds the pgx pool) via a new
ResolveUsername method that mirrors Validate's display-name logic — the
profile name when a profile is set and named, else the account username.
handleMe uses it and falls back to the userID when unresolved.

abs package compiles + tests pass; the audiobooks package (service.go,
cred_validator.go) could not be linked locally (pre-existing bimg/libvips
pkg-config gap) and is validated at the Docker build.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 39ff3e3350aad087757f7fb0e94d5a7f10c08ae5)

* fix(abs): always emit AudioTrack keys + correct media.duration

Two item-detail issues that made Prologue report "Unable to load book
contents" (can't press Start Listening):

- AudioTrack used omitempty on chapters/metaTags/format/bitRate/codec/
  metadata/etc, so empty values dropped those keys. Real ABS AudioFile/
  AudioTrack always emit them; strict clients (Prologue, yaabsa) decode
  tracks into a required-field model and throw keyNotFound on the missing
  keys, failing the whole track decode. Removed omitempty and emit
  chapters/metaTags as [] / {} (non-nil) in both track builders.
- media.duration used the item's Runtime, which is often stale/mis-scanned
  (e.g. 222s for a 3.7h book) and desyncs the player scrubber. Now sum the
  track durations (real ABS: sum of audio file durations), falling back to
  Runtime only when there are no tracks.

Verified against advplyr/audiobookshelf models/Book.js (AudioFile/AudioTrack)
via a live packet capture of Prologue's item-detail decode failure. abs
suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8210ed2fc63e782f158b4168ef693e671ae19638)

* perf(abs): push down library browse filters + author counts MV

The ABS audiobook library-serving path was slow on large libraries
(~255k items): /libraries/{id}/items?filter=authors.{id} loaded and
hydrated the whole library into Go before filtering (~4.8s each), and
/libraries/{id}/authors ran a full GroupAggregate + COUNT(DISTINCT)
per page (~53s full sync) — slow enough to trip ABS client sync
timeouts (e.g. Prologue).

- Push author/series/narrator/no-series filters into indexed SQL
  EXISTS predicates in ListAudiobooks; paginate + COUNT in SQL.
  Semantically equivalent to the prior Go-side filter (kind=7 author,
  kind=8 narrator, exact-case match, no-series sentinel).
- Add covering index media_items(content_id, type) so the count/list
  type check runs index-only (CONCURRENTLY, NO TRANSACTION — no
  write-lock on the live table).
- Serve /authors from a materialized view (abs_audiobook_author_counts)
  refreshed every 15min, with a live-query fallback when the view is
  empty/unrefreshed so the endpoint never blanks on a fresh deploy.

Conformance preserved: keeps authorObjectABS/seriesObjectABS shapes and
the limit&&page envelope decision; adds a regression test for the
bare {authors:[...]} envelope on limit-only requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0d55754051dd3ad016b3cec6a0921307071d3219)

* perf(abs): index-back audiobook search via trigram GIN

SearchAudiobooks matched the raw media_items.title with ILIKE '%q%'
OR'd with an author/narrator EXISTS. The un-indexed raw-title column
plus the OR forced a full seq scan of the ~255k-item library on every
search (~560ms on library 18).

Reshape into a UNION of two index-driven arms that reuse the search
infrastructure the rest of the catalog already relies on: the title arm
matches media_items.title_normalized (idx_media_items_title_normalized_trgm)
via the shared normalize_search_text(), the people arm matches people.name
(idx_people_name_trgm). GROUP BY content_id keeps the best rank when an
item matches both; a normalize_search_text($2) <> '' guard stops a
punctuation-only query from degenerating into ILIKE '%%'.

No new index or migration — the trigram indexes already existed and were
simply unused. ~560ms -> ~35ms, both indexes engaged, no seq scan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 98bbd1712719ecd03e4db87f840774cec788f177)

* perf(abs): index-ordered item paging + cached library count

The unfiltered /libraries/{id}/items path that ABS clients page through
to sync a library recomputed COUNT(*) over the whole library on every
page (~150ms each) and ordered by LOWER(sort_title), LOWER(title) — an
expression matching no index, forcing a full in-memory sort of all
~255k rows per page (~324ms shallow, ~543ms deep). A full sync is
thousands of pages, so both costs dominated indexing time.

- Order by lower(coalesce(nullif(btrim(sort_title),''), title)),
  content_id so the page is served by an ordered index scan on the
  existing idx_media_items_sort_key (~324ms -> ~1ms). content_id (PK)
  is a stable tiebreaker, making sequential pagination deterministic —
  the prior ordering could skip/repeat rows when sort keys collided.
- Memoize the per-page COUNT in a 60s TTL cache keyed on the fully
  rendered count SQL + bound args, so it covers every input the WHERE
  depends on (library, pushed-down filter, all access predicates) and
  can't drift as access logic evolves. Expired entries swept on write.

No new index or migration — reuses idx_media_items_sort_key.
total may lag up to 60s during an active scan; clients re-sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 32e26c2f99a1ffc071f600071c2ea7ddcd3397b4)

* fix(abs): address PR review — access-aware authors, offline progress create, cookie refresh, body limits

- media_store: ListLibraryAuthors bypassed per-item access when reading the
  author materialized view (keyed by library only), leaking authors of books
  hidden by a content-rating cap or excluded media types. Take the access-aware
  live path whenever the filter carries an item-level predicate.
- session_local: offline sync used UPDATE-only UpdateProgressPosition, so a book
  listened to entirely offline (no progress row yet) had its position silently
  dropped while still reporting progressSynced. Create the row via UpsertProgress
  when none exists; keep the monotonic update path for existing rows.
- login: handleRefresh never read the refresh_token cookie, so cookie-flow ABS
  clients got 400 refreshToken required once the access token expired. Read the
  cookie as a third source after header and body.
- session_local: cap /session/local and /session/local-all request bodies at
  1 MiB via io.LimitReader, matching the rest of the package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:16:34 -04:00
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00