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.
This commit is contained in:
2026-08-22 09:57:35 -04:00
parent 078c4b1f3f
commit 6859f81144
2 changed files with 38 additions and 0 deletions
+37
View File
@@ -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
+1
View File
@@ -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))