Files
bookhoard/internal/services/book_matching.go
T
John O'Keefe 040c10ac76 feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:

- QueryBooks post-filters the cascade by the requester's visible-library
  set (absent-from-results — a hidden library's book simply never
  matches), before determineAction so the suggested action reflects the
  filtered set. BookQueryRequest gains a server-side-only
  VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
  (DeviceExistsForUser) and the target item's library is visible —
  403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
  still a stub, but the route no longer answers for other users'
  devices).

Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.

And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.

Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
2026-10-18 13:10:57 -04:00

397 lines
11 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"`
// 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.
if 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, or unscoped — drop
}
if allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
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
}