From 6859f8114408db19f18a1ef39c764ed9a0464863 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 22 Aug 2026 09:57:35 -0400 Subject: [PATCH] feat(sync): add read-only KOReader book resolve endpoint GET /api/sync/koreader/resolve?sha256={hash} maps a file content hash to the book's UUID through the shared format-aware BookResolver (primary media_items hash, then per-format hashes so converted KEPUB/PDF files match) without touching any progress state. Devices need the UUID to pull metadata, but a freshly downloaded book has none cached. The old way of learning it was to push once, which transmitted the device's first-page position and manufactured a progress conflict for books already mid-read from another source. A read-only lookup lets clients link (and pull) without ever pushing bootstrap progress: resolve, then pull, then push. Returns 200 {book_uuid, sha256, title, author}, 400 for a missing or malformed hash, 404 when no library item matches. --- internal/handlers/koreader.go | 37 +++++++++++++++++++++++++++++++++++ internal/router/sync.go | 1 + 2 files changed, 38 insertions(+) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 9771a66..d2a2826 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -883,6 +883,43 @@ func int64PtrToPgInt8(i *int64) pgtype.Int8 { return pgtype.Int8{} } +type KOReaderResolveResponse struct { + BookUUID string `json:"book_uuid"` + SHA256 string `json:"sha256,omitempty"` + Title string `json:"title,omitempty"` + Author string `json:"author,omitempty"` +} + +// ResolveBook maps a file SHA-256 to the book's UUID without touching any +// progress state. Devices need the UUID to pull metadata, but a freshly +// downloaded book has none cached yet — the old way of learning it was to +// push once, which transmitted the device's first-page position to the +// server and manufactured a progress conflict for books already mid-read +// from another source. This read-only lookup lets the client link (and +// pull) without ever pushing bootstrap progress. +func (h *KOReaderHandler) ResolveBook(c *echo.Context) error { + sha256 := c.QueryParam("sha256") + if sha256 == "" || len(sha256) != 64 { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "sha256 query parameter is required (64 hex characters)", + }) + } + + mediaItem, _, err := h.bookResolver.ResolveBySHA256(c.Request().Context(), sha256) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "book not found", + }) + } + + return c.JSON(http.StatusOK, KOReaderResolveResponse{ + BookUUID: uuid.UUID(mediaItem.ID.Bytes).String(), + SHA256: sha256, + Title: mediaItem.Title, + Author: mediaItem.Author.String, + }) +} + func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { device := c.Get("device").(database.Devices) userID := device.UserID.Bytes diff --git a/internal/router/sync.go b/internal/router/sync.go index 406e453..3e0db37 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -24,6 +24,7 @@ func registerSyncRoutes(cfg *Config) { // KOReader sync routes (device authentication required) koreaderSync := e.Group("/api/sync/koreader") koreaderSync.POST("/progress", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncProgress)) + koreaderSync.GET("/resolve", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.ResolveBook)) koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata)) koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary)) koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))