/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.
397 lines
11 KiB
Go
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
|
|
}
|