feat(sync): add shared BookResolver with format-aware SHA-256 matching

The platform had three duplicated, divergent book resolvers (koreader,
kobo, BookMatchingService) and none of them consulted
media_item_formats.file_sha256 - per-format hashes for converted files
(KEPUB, PDF) are computed and stored at import/conversion time but were
never used for lookup. GetMediaItemFormatBySHA256 existed with zero
callers. Any client holding a converted file could never match by
hash.

Add internal/services/book_resolver.go: a single shared resolution
path from client-supplied identifier to media_item.
ResolveBySHA256 checks media_items.file_sha256 first (indexed
GetMediaItemBySHA256), then falls back to media_item_formats.
file_sha256 (indexed GetMediaItemFormatBySHA256, first caller) so a
converted format matches with equal confidence. The import-time
SHA-256 is the canonical identifier shared by every interface.

Wire two of the existing resolvers through it:

- BookMatchingService.matchBySHA256: replaces the in-memory
  ListMediaItems scan of up to 1000 rows with the resolver's indexed
  lookups, and gains format awareness for the link/auto-link UI.
  MatchMethod now reports sha256_sha256 or sha256_sha256_format
- KoboHandler.mapContentIdToBookhoardUUID: the SHA-256 heuristic
  branch (ContentId that looks like a 64-char hash) now resolves
  format-aware too. Kobo's entitlement_id wire identity is untouched;
  only the opportunistic hash branch changed
This commit is contained in:
2026-08-14 08:25:23 -04:00
parent 9b171a0060
commit 60a94df8e1
3 changed files with 89 additions and 22 deletions
+6 -3
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"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -22,10 +23,11 @@ type KoboHandler struct {
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver libraryService LibraryPathResolver
bookResolver *services.BookResolver
} }
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler { func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
return &KoboHandler{db: db, connManager: connManager} return &KoboHandler{db: db, connManager: connManager, bookResolver: services.NewBookResolver(db)}
} }
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) { func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
@@ -52,8 +54,9 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
// Step 2: ContentId not found - check if it looks like a SHA-256 hash // Step 2: ContentId not found - check if it looks like a SHA-256 hash
if len(contentId) == 64 && looksLikeSHA256(contentId) { if len(contentId) == 64 && looksLikeSHA256(contentId) {
// Try to find media item by SHA-256 // Try to find media item by SHA-256 (format-aware: also checks
mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true}) // media_item_formats, so a converted/alternate format hash matches).
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
if err == nil { if err == nil {
// Found by SHA-256! Create device catalog entry for future lookups // Found by SHA-256! Create device catalog entry for future lookups
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{ _, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
+15 -19
View File
@@ -46,13 +46,15 @@ type LinkBookRequest struct {
// BookMatchingService handles universal book matching // BookMatchingService handles universal book matching
type BookMatchingService struct { type BookMatchingService struct {
db *database.Queries db *database.Queries
resolver *BookResolver
} }
// NewBookMatchingService creates a new book matching service // NewBookMatchingService creates a new book matching service
func NewBookMatchingService(db *database.Queries) *BookMatchingService { func NewBookMatchingService(db *database.Queries) *BookMatchingService {
return &BookMatchingService{ return &BookMatchingService{
db: db, db: db,
resolver: NewBookResolver(db),
} }
} }
@@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
return nil return nil
} }
// matchBySHA256 attempts to match by file SHA-256 hash // matchBySHA256 attempts to match by file SHA-256 hash.
// Uses the shared BookResolver so it is both indexed (no full-table scan) and
// format-aware: a converted/alternate format hash (media_item_formats) matches
// in addition to the primary media_items.file_sha256.
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch { func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
Limit: 1000, if err != nil || !item.ID.Valid {
Offset: 0,
})
if err != nil {
return nil return nil
} }
return &BookMatch{
for _, item := range items { MediaItemID: item.ID.Bytes,
if item.FileSha256.Valid && item.FileSha256.String == sha256 { BookhoardUUID: item.ID.Bytes,
return &BookMatch{ Confidence: 0.9,
MediaItemID: item.ID.Bytes, MatchMethod: "sha256_" + string(method),
BookhoardUUID: item.ID.Bytes,
Confidence: 0.9,
MatchMethod: "sha256_match",
}
}
} }
return nil
} }
// matchByOPFIdentifier attempts to match by OPF identifier // matchByOPFIdentifier attempts to match by OPF identifier
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// ResolveMethod describes how a media item was resolved from a client-supplied identifier.
type ResolveMethod string
const (
MethodNone ResolveMethod = ""
MethodSHA256 ResolveMethod = "sha256" // matched on media_items.file_sha256
MethodSHA256Format ResolveMethod = "sha256_format" // matched on media_item_formats.file_sha256 (converted/alternate format)
)
// BookResolver is the single shared path from a client-supplied identifier to a
// media_item.
//
// All client/sync interfaces (koreader, kobo, OPDS, the device-link UI, and any
// future mobile app) should resolve books through BookResolver so they share
// identical matching semantics. In particular it provides format-aware SHA-256
// matching: a converted file (KEPUB/PDF) whose hash lives in media_item_formats
// resolves just as well as the primary format. The import-time SHA-256 is the
// canonical shared identifier across every client.
type BookResolver struct {
db *database.Queries
}
// NewBookResolver constructs a resolver backed by the given queries.
func NewBookResolver(db *database.Queries) *BookResolver {
return &BookResolver{db: db}
}
// ResolveBySHA256 resolves a media item by its content hash. It checks the
// primary media_items.file_sha256 first, then media_item_formats.file_sha256 so
// that a converted/alternate format (KEPUB, PDF, ...) also matches. Returns the
// matched item and how it matched, or pgx.ErrNoRows when no item has this hash.
func (r *BookResolver) ResolveBySHA256(ctx context.Context, sha256 string) (database.MediaItems, ResolveMethod, error) {
if sha256 == "" {
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
}
sha := pgtype.Text{String: sha256, Valid: true}
// 1. Primary content hash (the file the media item was imported from).
if mi, err := r.db.GetMediaItemBySHA256(ctx, sha); err == nil {
return mi, MethodSHA256, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (primary): %w", err)
}
// 2. Per-format hash (a converted/alternate format: KEPUB, PDF, ...).
formatRow, err := r.db.GetMediaItemFormatBySHA256(ctx, sha)
if err == nil {
if mi, err := r.db.GetMediaItem(ctx, formatRow.MediaItemID); err == nil {
return mi, MethodSHA256Format, nil
}
} else if !errors.Is(err, pgx.ErrNoRows) {
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (format): %w", err)
}
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
}