diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 73ab1ee..bc093a3 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -2,6 +2,7 @@ package handlers import ( "bookhoard/internal/database" + "bookhoard/internal/services" wsync "bookhoard/internal/sync" "encoding/json" "fmt" @@ -22,10 +23,11 @@ type KoboHandler struct { progressSvc *wsync.ProgressService annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver + bookResolver *services.BookResolver } 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) { @@ -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 if len(contentId) == 64 && looksLikeSHA256(contentId) { - // Try to find media item by SHA-256 - mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true}) + // Try to find media item by SHA-256 (format-aware: also checks + // media_item_formats, so a converted/alternate format hash matches). + mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId) if err == nil { // Found by SHA-256! Create device catalog entry for future lookups _, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{ diff --git a/internal/services/book_matching.go b/internal/services/book_matching.go index 4fb5f4d..75f8747 100644 --- a/internal/services/book_matching.go +++ b/internal/services/book_matching.go @@ -46,13 +46,15 @@ type LinkBookRequest struct { // BookMatchingService handles universal book matching type BookMatchingService struct { - db *database.Queries + db *database.Queries + resolver *BookResolver } // NewBookMatchingService creates a new book matching service func NewBookMatchingService(db *database.Queries) *BookMatchingService { return &BookMatchingService{ - db: db, + db: db, + resolver: NewBookResolver(db), } } @@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers [] 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 { - items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ - Limit: 1000, - Offset: 0, - }) - if err != nil { + item, method, err := s.resolver.ResolveBySHA256(ctx, sha256) + if err != nil || !item.ID.Valid { return nil } - - for _, item := range items { - if item.FileSha256.Valid && item.FileSha256.String == sha256 { - return &BookMatch{ - MediaItemID: item.ID.Bytes, - BookhoardUUID: item.ID.Bytes, - Confidence: 0.9, - MatchMethod: "sha256_match", - } - } + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookhoardUUID: item.ID.Bytes, + Confidence: 0.9, + MatchMethod: "sha256_" + string(method), } - return nil } // matchByOPFIdentifier attempts to match by OPF identifier diff --git a/internal/services/book_resolver.go b/internal/services/book_resolver.go new file mode 100644 index 0000000..6be4aa3 --- /dev/null +++ b/internal/services/book_resolver.go @@ -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 +}