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.
This commit is contained in:
2026-08-14 08:25:46 -04:00
parent 60a94df8e1
commit 5584bdefb5
+20 -7
View File
@@ -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
}