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 }