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 ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"context" "context"
"encoding/json" "encoding/json"
@@ -22,6 +23,7 @@ type KOReaderHandler struct {
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver libraryService LibraryPathResolver
bookResolver *services.BookResolver
} }
type LibraryPathResolver interface { type LibraryPathResolver interface {
@@ -29,7 +31,12 @@ type LibraryPathResolver interface {
} }
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { 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) { func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
@@ -158,6 +165,7 @@ type KOReaderConflict struct {
type KOReaderMetadata struct { type KOReaderMetadata struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Authors []string `json:"authors"` Authors []string `json:"authors"`
Progress KOReaderProgressData `json:"progress"` Progress KOReaderProgressData `json:"progress"`
@@ -192,6 +200,7 @@ type KOReaderLibraryResponse struct {
type KOReaderLibraryBook struct { type KOReaderLibraryBook struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
ContentType string `json:"content_type"` 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) // 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 { 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 { if err == nil {
// Create device file alias if FilePath is provided // Create device file alias if FilePath is provided
if book.FilePath != "" { if book.FilePath != "" {
@@ -883,6 +894,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
metadata := KOReaderMetadata{ metadata := KOReaderMetadata{
UUID: bookUUID.String(), UUID: bookUUID.String(),
SHA256: mediaItem.FileSha256.String,
Title: mediaItem.Title, Title: mediaItem.Title,
Authors: []string{mediaItem.Author.String}, Authors: []string{mediaItem.Author.String},
Progress: progressData, Progress: progressData,
@@ -989,6 +1001,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
libraryBooks = append(libraryBooks, KOReaderLibraryBook{ libraryBooks = append(libraryBooks, KOReaderLibraryBook{
UUID: uuid.UUID(item.ID.Bytes).String(), UUID: uuid.UUID(item.ID.Bytes).String(),
SHA256: item.FileSha256.String,
Title: item.Title, Title: item.Title,
Author: item.Author.String, Author: item.Author.String,
ContentType: "6", ContentType: "6",
@@ -1045,8 +1058,8 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
} }
pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true} pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true}
} else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 { } else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 {
// Use SHA-256 to find book // Use SHA-256 to find book (format-aware: also checks media_item_formats)
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true}) mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx, req.BookSHA256)
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{ return c.JSON(http.StatusNotFound, map[string]string{
"error": "book not found by SHA-256", "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 has its own SHA-256, use it for matching
if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 { 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 { if err == nil {
mediaItemID = mediaItem.ID 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 has its own SHA-256, use it for matching
if note.BookSHA256 != "" && len(note.BookSHA256) == 64 { 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 { if err == nil {
mediaItemID = mediaItem.ID 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 has its own SHA-256, use it for matching
if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 { 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 { if err == nil {
mediaItemID = mediaItem.ID mediaItemID = mediaItem.ID
} }