feat(sync): unlinked-books queue — ingest, page route, dashboard banner
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.
This commit is contained in:
John O'Keefe committed 2026-10-18 16:45:48 -04:00
1 parent 9e831085ef
commit d74db03cf3
10 files changed
+823 -454

No files matched your search

+7
View File
@@ -48,6 +48,8 @@ type Querier interface {
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
// COLLECTIONS QUERIES
@@ -469,6 +471,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
@@ -607,6 +611,9 @@ type Querier interface {
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
}
var _ Querier = (*Queries)(nil)
+129
View File
@@ -332,6 +332,21 @@ func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID)
return count, err
}
const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
`
// Banner count: unresolved queue rows across the caller's own devices.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUserDevices = `-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1
`
@@ -11365,6 +11380,69 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
return items, nil
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC
`
type ListUnresolvedUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list: unresolved queue rows for the caller's own devices.
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUnresolvedUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListUnresolvedUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListUsers = `-- name: ListUsers :many
SELECT
u.id,
@@ -15382,3 +15460,54 @@ func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSetti
)
return i, err
}
const UpsertUnlinkedBook = `-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
`
type UpsertUnlinkedBookParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
}
// Ingest: insert or refresh the queue row for a device file that could not
// be identified. One row per (device, content_id); re-syncs bump last_seen.
func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) {
row := q.db.QueryRow(ctx, UpsertUnlinkedBook,
arg.DeviceID,
arg.ContentID,
arg.FilePath,
arg.Title,
arg.Author,
arg.ConfidenceScore,
)
var i UnlinkedBooks
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
)
return i, err
}
+28
View File
@@ -2619,6 +2619,34 @@ WHERE ub.resolved = false
ORDER BY ub.last_seen_at DESC
LIMIT $1 OFFSET $2;
-- Ingest: insert or refresh the queue row for a device file that could not
-- be identified. One row per (device, content_id); re-syncs bump last_seen.
-- name: UpsertUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (device_id, content_id) DO UPDATE
SET file_path = EXCLUDED.file_path,
title = EXCLUDED.title,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
-- Page list: unresolved queue rows for the caller's own devices.
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
ORDER BY ub.last_seen_at DESC;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
+43 -1
View File
@@ -451,6 +451,10 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
// Unidentified file: queue it for manual identification
// (unlinked-books page) instead of silently dropping the
// file's progress. One row per (device, content id).
h.queueUnlinkedBook(c, device.ID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
@@ -498,9 +502,47 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
// queueUnlinkedBook records a device file that could not be identified, so
// the user can resolve it on the unlinked-books page. One row per
// (device, content id); re-syncs refresh the row instead of duplicating it.
// Best-effort: queueing failures never fail the sync response.
func (h *KOReaderHandler) queueUnlinkedBook(c *echo.Context, deviceID pgtype.UUID, book KOReaderBookProgress) {
ctx := c.Request().Context()
contentID := book.UUID
if contentID == "" {
contentID = book.SHA256
}
if contentID == "" {
// Nothing stable to identify the file by — it could never resolve.
return
}
var title pgtype.Text
if book.Title != "" {
title = pgtype.Text{String: book.Title, Valid: true}
}
var author pgtype.Text
if len(book.Authors) > 0 && book.Authors[0] != "" {
author = pgtype.Text{String: book.Authors[0], Valid: true}
}
var filePath pgtype.Text
if book.FilePath != "" {
filePath = pgtype.Text{String: book.FilePath, Valid: true}
}
_, _ = h.db.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
DeviceID: deviceID,
ContentID: contentID,
FilePath: filePath,
Title: title,
Author: author,
ConfidenceScore: pgtype.Float8{Float64: 0, Valid: true},
})
}
func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) { ctx := c.Request().Context()
// Priority 1: UUID provided (highest confidence - 1.0)
if book.UUID != "" {
mediaUUID, err := uuid.Parse(book.UUID)
+50 -1
View File
@@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) {
}
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if countErr != nil {
log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr)
unlinkedCount = 0
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -742,6 +752,45 @@ func registerFrontendRoutes(cfg *Config) {
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if listErr != nil {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,