feat(sync): add shared BookResolver with format-aware SHA-256 matching

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
This commit is contained in:
2026-08-14 08:25:23 -04:00
parent 9b171a0060
commit 60a94df8e1
3 changed files with 89 additions and 22 deletions
+15 -19
View File
@@ -46,13 +46,15 @@ type LinkBookRequest struct {
// BookMatchingService handles universal book matching
type BookMatchingService struct {
db *database.Queries
db *database.Queries
resolver *BookResolver
}
// NewBookMatchingService creates a new book matching service
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
return &BookMatchingService{
db: db,
db: db,
resolver: NewBookResolver(db),
}
}
@@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
return nil
}
// matchBySHA256 attempts to match by file SHA-256 hash
// 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 {
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
if err != nil || !item.ID.Valid {
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 &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.9,
MatchMethod: "sha256_" + string(method),
}
return nil
}
// matchByOPFIdentifier attempts to match by OPF identifier