Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now live end-to-end. Previously the whole feature was dark: unmatched device files were silently dropped (losing their reading progress), the queue table sat empty by construction, and the page template had no route. - Ingest: the KOReader sync loop, on an unresolvable file, upserts unlinked_books (one row per device + content id; re-syncs bump last_seen_at and refresh device-reported metadata) instead of dropping the file. Schema gains the (device_id, content_id) unique index the upsert needs. - Page: GET /unlinked-books routes the existing page template, listing the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser — devices.user_id scoping; no cross-user leakage). - Dashboard banner: a warning card above the shelves, rendered only when the caller's queue is non-empty (hidden-unless-needed), linking to the page. - Resolve flow unchanged and already gated: MatchingHandler.LinkBook verifies device ownership and target-item visibility (403s) and creates the device file alias, so the next sync matches by path. SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers — triage candidates, not part of the live flow.