Files
bookhoard/internal/services/book_matching.go
T
John O'Keefe 327fc2c0f9 feat(matching): match results as a scoped templ partial + enriched matches
- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
2026-10-18 16:00:04 -04:00

410 lines
12 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"fmt"
"math"
"bookhoard/internal/utils"
"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"`
// Display fields, filled during the family-lens pass in QueryBooks.
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
// 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"`
// VisibleLibraryIDs scopes results to the requesting user's visible
// libraries (family-lens, absent-from-results). Set server-side by the
// handler — never from client input. nil keeps legacy callers unfiltered.
VisibleLibraryIDs []string `json:"-"`
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...)
}
}
// Family-lens: matches may only surface items from libraries the
// requesting user can see — a hidden library's book simply never
// matches (absent-from-results). Runs before determineAction so the
// suggested action reflects the filtered set. nil (unset) keeps
// legacy callers unfiltered.
// Enrich + scope in one pass over the cascade hits: display fields come
// from the resolved item, and when scoped the family-lens filter drops
// matches from libraries the requester cannot see
// (absent-from-results).
scoped := req.VisibleLibraryIDs != nil
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
for _, id := range req.VisibleLibraryIDs {
allowed[id] = true
}
filtered := matches[:0]
for _, m := range matches {
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
if err != nil || !item.LibraryID.Valid {
continue // vanished between cascade and filter — drop
}
m.Title = item.Title
m.Author = utils.PgTextString(item.Author)
m.CoverImagePath = utils.PgTextString(item.CoverImagePath)
if scoped && !allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
continue
}
filtered = append(filtered, m)
}
matches = filtered
// 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
}