Files
bookhoard/internal/services/book_matching.go
T
john-okeefe 60a94df8e1 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
2026-08-14 08:25:23 -04:00

370 lines
10 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"fmt"
"math"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// BookMatch represents a potential match with confidence score
type BookMatch struct {
MediaItemID uuid.UUID `json:"media_item_id"`
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"`
}
// BookQueryRequest represents a book query from a device
type BookQueryRequest struct {
Identifiers []string `json:"identifiers"` // ["isbn:...", "uuid:...", "opf_uuid:..."]
SHA256 string `json:"sha256"`
Title string `json:"title"`
Author string `json:"author"`
FileSize int64 `json:"file_size"`
}
// BookQueryResponse represents the response to a book query
type BookQueryResponse struct {
Matches []BookMatch `json:"matches"`
Action string `json:"action"` // "auto_link", "multiple_matches", "no_match"
}
// LinkBookRequest represents a manual linking request
type LinkBookRequest struct {
DeviceFile struct {
FilePath string `json:"file_path"`
SHA256 string `json:"sha256"`
Title string `json:"title"`
} `json:"device_file"`
MediaItemID uuid.UUID `json:"media_item_id"`
ConfidenceScore float64 `json:"confidence_score"`
}
// BookMatchingService handles universal book matching
type BookMatchingService struct {
db *database.Queries
resolver *BookResolver
}
// NewBookMatchingService creates a new book matching service
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
return &BookMatchingService{
db: db,
resolver: NewBookResolver(db),
}
}
// QueryBooks queries for a book using multiple identifier types with confidence scoring
func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequest) (*BookQueryResponse, error) {
var matches []BookMatch
// Priority 1: Bookhoard UUID (canonical) - Confidence: 1.0
if match := s.matchByBookhoardUUID(ctx, req.Identifiers); match != nil {
matches = append(matches, *match)
}
// Priority 2: OPF UUID (from EPUB metadata) - Confidence: 0.95
if match := s.matchByOPFUUID(ctx, req.Identifiers); match != nil {
matches = append(matches, *match)
}
// Priority 3: SHA-256 hash - Confidence: 0.9
if req.SHA256 != "" {
if match := s.matchBySHA256(ctx, req.SHA256); match != nil {
matches = append(matches, *match)
}
}
// Priority 4: OPF identifier (non-UUID) - Confidence: 0.85
if match := s.matchByOPFIdentifier(ctx, req.Identifiers); match != nil {
matches = append(matches, *match)
}
// Priority 5: ISBN/ASIN - Confidence: 0.8
if match := s.matchByISBNASIN(ctx, req.Identifiers); match != nil {
matches = append(matches, *match)
}
// Priority 6: File path (device-specific) - Need device_id for this
// This will be handled at API layer with device context
// Priority 7: Title + author + file size - Confidence: 0.5
if req.Title != "" && req.Author != "" && req.FileSize > 0 {
if matches := s.matchByTitleAuthorSize(ctx, req.Title, req.Author, req.FileSize); len(matches) > 0 {
matches = append(matches, matches...)
}
}
// Priority 8: Title only (last resort) - Confidence: 0.3
if req.Title != "" && len(matches) == 0 {
if matches := s.matchByTitleOnly(ctx, req.Title); len(matches) > 0 {
matches = append(matches, matches...)
}
}
// Determine action
action := s.determineAction(matches)
return &BookQueryResponse{
Matches: matches,
Action: action,
}, nil
}
// matchByBookhoardUUID attempts to match by Bookhoard UUID
func (s *BookMatchingService) matchByBookhoardUUID(ctx context.Context, identifiers []string) *BookMatch {
for _, id := range identifiers {
if len(id) > 4 && id[:4] == "uuid:" {
uuidStr := id[5:]
parsedUUID, err := uuid.Parse(uuidStr)
if err != nil {
continue
}
// Check if media item exists
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err == nil {
mediaUUID := item.ID.Bytes
return &BookMatch{
MediaItemID: mediaUUID,
BookhoardUUID: mediaUUID,
Confidence: 1.0,
MatchMethod: "uuid_match",
}
}
}
}
return nil
}
// matchByOPFUUID attempts to match by OPF UUID
func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []string) *BookMatch {
for _, id := range identifiers {
if len(id) > 8 && id[:8] == "opf_uuid:" {
uuidStr := id[9:]
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 100,
Offset: 0,
})
if err != nil {
continue
}
for _, item := range items {
if item.OpfUuid.Valid && item.OpfUuid.String == uuidStr {
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.95,
MatchMethod: "opf_uuid_match",
}
}
}
}
}
return nil
}
// 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 {
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
if err != nil || !item.ID.Valid {
return nil
}
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.9,
MatchMethod: "sha256_" + string(method),
}
}
// matchByOPFIdentifier attempts to match by OPF identifier
func (s *BookMatchingService) matchByOPFIdentifier(ctx context.Context, identifiers []string) *BookMatch {
for _, id := range identifiers {
if len(id) > 15 && id[:15] == "opf_identifier:" {
identifier := id[16:]
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
continue
}
for _, item := range items {
if item.OpfIdentifier.Valid && item.OpfIdentifier.String == identifier {
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.85,
MatchMethod: "opf_identifier_match",
}
}
}
}
}
return nil
}
// matchByISBNASIN attempts to match by ISBN or ASIN
func (s *BookMatchingService) matchByISBNASIN(ctx context.Context, identifiers []string) *BookMatch {
for _, id := range identifiers {
if len(id) > 5 && id[:5] == "isbn:" {
isbn := id[6:]
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
continue
}
for _, item := range items {
if item.Isbn.Valid && item.Isbn.String == isbn {
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.8,
MatchMethod: "isbn_match",
}
}
}
}
if len(id) > 5 && id[:5] == "asin:" {
asin := id[6:]
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
continue
}
for _, item := range items {
if item.Asin.Valid && item.Asin.String == asin {
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.8,
MatchMethod: "asin_match",
}
}
}
}
}
return nil
}
// matchByTitleAuthorSize attempts to match by title, author, and file size
func (s *BookMatchingService) matchByTitleAuthorSize(ctx context.Context, title, author string, fileSize int64) []BookMatch {
var matches []BookMatch
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return matches
}
for _, item := range items {
// Check title match (case-insensitive)
titleMatch := item.Title == title
// Check author match (case-insensitive)
authorMatch := item.Author.Valid && item.Author.String == author
// Check file size within 10%
sizeMatch := item.FileSize.Valid && math.Abs(float64(item.FileSize.Int64-fileSize))/float64(fileSize) <= 0.1
if titleMatch && authorMatch && sizeMatch {
matches = append(matches, BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.5,
MatchMethod: "title_author_size_match",
})
}
}
return matches
}
// matchByTitleOnly attempts to match by title only (last resort)
func (s *BookMatchingService) matchByTitleOnly(ctx context.Context, title string) []BookMatch {
var matches []BookMatch
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return matches
}
for _, item := range items {
if item.Title == title {
matches = append(matches, BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.3,
MatchMethod: "title_match",
})
}
}
return matches
}
// determineAction determines the action to take based on matches
func (s *BookMatchingService) determineAction(matches []BookMatch) string {
if len(matches) == 0 {
return "no_match"
}
if len(matches) == 1 {
// Auto-link if confidence is high enough (> 0.7)
if matches[0].Confidence > 0.7 {
return "auto_link"
}
}
return "multiple_matches"
}
// LinkBook manually links a device file to a media item
func (s *BookMatchingService) LinkBook(ctx context.Context, deviceID uuid.UUID, req *LinkBookRequest) (*database.DeviceFileAliases, error) {
// Create device file alias
alias, err := s.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
MediaItemID: pgtype.UUID{Bytes: req.MediaItemID, Valid: true},
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
FilePath: req.DeviceFile.FilePath,
FileSha256: pgtype.Text{String: req.DeviceFile.SHA256, Valid: req.DeviceFile.SHA256 != ""},
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
})
if err != nil {
return nil, fmt.Errorf("failed to create device file alias: %v", err)
}
return &alias, nil
}
// GetUnlinkedBooks returns books that need manual linking
func (s *BookMatchingService) GetUnlinkedBooks(ctx context.Context, deviceID uuid.UUID) ([]map[string]interface{}, error) {
// Get device file aliases that don't have media_item_id set
// This is a placeholder - actual implementation would query progress records
// that exist without matching media items
return []map[string]interface{}{}, nil
}