Changes: - Update comments: "Bookmann UUID" → "Bookhoard UUID" - Rename sidecar struct field: Bookmann → Bookhoard - Update type names: SidecarBookmannConfig → SidecarBookhoardConfig - Fix test database name in queue_test.go - Fix uppercase env var examples in KOBO_SETUP.md Internal Go variable names (BookmannUuid, bookmannUUID) left unchanged as they're implementation details that don't affect functionality. Part of project rename to Bookhoard.
374 lines
10 KiB
Go
374 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
|
|
}
|
|
|
|
// NewBookMatchingService creates a new book matching service
|
|
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
|
return &BookMatchingService{
|
|
db: 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
|
|
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
|
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
|
Limit: 1000,
|
|
Offset: 0,
|
|
})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
for _, item := range items {
|
|
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
|
|
return &BookMatch{
|
|
MediaItemID: item.ID.Bytes,
|
|
BookhoardUUID: item.ID.Bytes,
|
|
Confidence: 0.9,
|
|
MatchMethod: "sha256_match",
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|