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.