The platform had three duplicated, divergent book resolvers (koreader, kobo, BookMatchingService) and none of them consulted media_item_formats.file_sha256 - per-format hashes for converted files (KEPUB, PDF) are computed and stored at import/conversion time but were never used for lookup. GetMediaItemFormatBySHA256 existed with zero callers. Any client holding a converted file could never match by hash. Add internal/services/book_resolver.go: a single shared resolution path from client-supplied identifier to media_item. ResolveBySHA256 checks media_items.file_sha256 first (indexed GetMediaItemBySHA256), then falls back to media_item_formats. file_sha256 (indexed GetMediaItemFormatBySHA256, first caller) so a converted format matches with equal confidence. The import-time SHA-256 is the canonical identifier shared by every interface. Wire two of the existing resolvers through it: - BookMatchingService.matchBySHA256: replaces the in-memory ListMediaItems scan of up to 1000 rows with the resolver's indexed lookups, and gains format awareness for the link/auto-link UI. MatchMethod now reports sha256_sha256 or sha256_sha256_format - KoboHandler.mapContentIdToBookhoardUUID: the SHA-256 heuristic branch (ContentId that looks like a 64-char hash) now resolves format-aware too. Kobo's entitlement_id wire identity is untouched; only the opportunistic hash branch changed
370 lines
10 KiB
Go
370 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
|
|
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
|
|
}
|