From 5584bdefb55b3e7fff8c4bdb8c5d29e3794ab821 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 14 Aug 2026 08:25:46 -0400 Subject: [PATCH] feat(koreader): resolve pushes by SHA via BookResolver and return SHA on pull Fixes the 'cannot push until pulling first' wall on books downloaded via OPDS. Root cause chain: the bookhoard koreader plugin only learns the book UUID from a successful push response, but the first push had to match by SHA-256 alone - and that match consulted only media_items.file_sha256, missing converted formats. When the hash missed, no UUID was returned, so pull stayed blocked (it requires the UUID) and the book could not sync at all. Resolution side - route all five SHA-256 match sites through the shared BookResolver so they are format-aware: - resolveBookToMediaItem priority 2 - SyncBookmarks book-level lookup - per-bookmark, per-note, and per-highlight override lookups Exposure side - return the canonical hash so clients can learn and cache it from a pull regardless of how the book was obtained: - KOReaderMetadata gains sha256, populated from mediaItem.FileSha256 - KOReaderLibraryBook gains sha256, populated the same way, so the library list endpoint carries it for every book Together with the plugin-side UUID bootstrap (bookhoard.koplugin), push and pull now work in either order on any format. --- internal/handlers/koreader.go | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index a19775e..d02180f 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -2,6 +2,7 @@ package handlers import ( "bookhoard/internal/database" + "bookhoard/internal/services" wsync "bookhoard/internal/sync" "context" "encoding/json" @@ -22,6 +23,7 @@ type KOReaderHandler struct { progressSvc *wsync.ProgressService annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver + bookResolver *services.BookResolver } type LibraryPathResolver interface { @@ -29,7 +31,12 @@ type LibraryPathResolver interface { } func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { - return &KOReaderHandler{db: db, connManager: connManager, queue: queue} + return &KOReaderHandler{ + db: db, + connManager: connManager, + queue: queue, + bookResolver: services.NewBookResolver(db), + } } func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) { @@ -158,6 +165,7 @@ type KOReaderConflict struct { type KOReaderMetadata struct { UUID string `json:"uuid"` + SHA256 string `json:"sha256,omitempty"` Title string `json:"title"` Authors []string `json:"authors"` Progress KOReaderProgressData `json:"progress"` @@ -192,6 +200,7 @@ type KOReaderLibraryResponse struct { type KOReaderLibraryBook struct { UUID string `json:"uuid"` + SHA256 string `json:"sha256,omitempty"` Title string `json:"title"` Author string `json:"author"` ContentType string `json:"content_type"` @@ -303,8 +312,10 @@ func (h *KOReaderHandler) resolveBookToMediaItem(c *echo.Context, deviceID pgtyp } // Priority 2: SHA-256 provided (medium confidence - 0.9) + // Uses the shared BookResolver, which also checks per-format hashes + // (media_item_formats) so a converted file (KEPUB/PDF) matches too. if book.SHA256 != "" && len(book.SHA256) == 64 { - mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: book.SHA256, Valid: true}) + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, book.SHA256) if err == nil { // Create device file alias if FilePath is provided if book.FilePath != "" { @@ -883,6 +894,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { metadata := KOReaderMetadata{ UUID: bookUUID.String(), + SHA256: mediaItem.FileSha256.String, Title: mediaItem.Title, Authors: []string{mediaItem.Author.String}, Progress: progressData, @@ -989,6 +1001,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { libraryBooks = append(libraryBooks, KOReaderLibraryBook{ UUID: uuid.UUID(item.ID.Bytes).String(), + SHA256: item.FileSha256.String, Title: item.Title, Author: item.Author.String, ContentType: "6", @@ -1045,8 +1058,8 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { } pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true} } else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 { - // Use SHA-256 to find book - mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true}) + // Use SHA-256 to find book (format-aware: also checks media_item_formats) + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, req.BookSHA256) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{ "error": "book not found by SHA-256", @@ -1068,7 +1081,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { // If bookmark has its own SHA-256, use it for matching if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 { - mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: bookmark.BookSHA256, Valid: true}) + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, bookmark.BookSHA256) if err == nil { mediaItemID = mediaItem.ID } @@ -1119,7 +1132,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { // If note has its own SHA-256, use it for matching if note.BookSHA256 != "" && len(note.BookSHA256) == 64 { - mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: note.BookSHA256, Valid: true}) + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, note.BookSHA256) if err == nil { mediaItemID = mediaItem.ID } @@ -1169,7 +1182,7 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { // If highlight has its own SHA-256, use it for matching if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 { - mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: highlight.BookSHA256, Valid: true}) + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, highlight.BookSHA256) if err == nil { mediaItemID = mediaItem.ID }