Files
silo-server/internal/literaryworks/matcher.go
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00

64 lines
2.5 KiB
Go

package literaryworks
const AutoLinkThreshold = 0.86
type MatchItem struct {
ContentID string
Type string
Title string
Authors []string
Narrators []string
SeriesName string
SeriesIndex *float64
ExternalIDs map[string]string
Publisher string
Year int
}
func ScoreCandidate(source, target MatchItem) Candidate {
if source.ContentID == "" || target.ContentID == "" || source.ContentID == target.ContentID {
return Candidate{Score: 0, Evidence: map[string]string{"reason": "same_or_missing_content_id"}}
}
evidence := map[string]string{}
sourceAuthor := personKey(source.Authors)
targetAuthor := personKey(target.Authors)
titleMatch := normalizeKey(source.Title) != "" && normalizeKey(source.Title) == normalizeKey(target.Title)
authorMatch := sourceAuthor != "" && sourceAuthor == targetAuthor
seriesMatch := normalizeKey(source.SeriesName) != "" &&
normalizeKey(source.SeriesName) == normalizeKey(target.SeriesName) &&
source.SeriesIndex != nil && target.SeriesIndex != nil &&
*source.SeriesIndex == *target.SeriesIndex
if provider, id, ok := sharedExternalID(source.ExternalIDs, target.ExternalIDs); ok {
evidence["external_id"] = provider + ":" + id
return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.98, LinkSource: LinkExternalID, Evidence: evidence}
}
if seriesMatch && authorMatch {
evidence["series"] = source.SeriesName
evidence["author"] = sourceAuthor
return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.92, LinkSource: LinkSeriesMatch, Evidence: evidence}
}
if titleMatch && authorMatch {
evidence["title"] = source.Title
evidence["author"] = sourceAuthor
return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.9, LinkSource: LinkMetadataMatch, Evidence: evidence}
}
if titleMatch && sourceAuthor != "" && targetAuthor != "" && sourceAuthor != targetAuthor {
evidence["conflict"] = "author"
return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.2, LinkSource: LinkMetadataMatch, Evidence: evidence}
}
return Candidate{SourceContentID: source.ContentID, TargetContentID: target.ContentID, Score: 0.4, LinkSource: LinkMetadataMatch, Evidence: evidence}
}
func sharedExternalID(a, b map[string]string) (string, string, bool) {
for provider, aID := range a {
if aID == "" || provider == "asin" {
continue
}
if bID := b[provider]; bID != "" && bID == aID {
return provider, aID, true
}
}
return "", "", false
}