Files
silo-server/internal/naming/group_identity.go
T
Quick ee54942b01 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00

343 lines
9.8 KiB
Go

package naming
import (
"encoding/json"
"fmt"
"hash/fnv"
"path/filepath"
"strings"
)
const ContentGroupKeyVersion = 1
type GroupIdentity struct {
GroupKeyVersion int
ContentGroupKey string
ObservedRootPath string
BaseTitle string
BaseYear int
BaseType string
Confidence string
TmdbID string
ImdbID string
TvdbID string
State string
EvidenceJSON []byte
RepresentativePath string
}
// InferGroupIdentity derives a logical content-group identity from a media
// file path plus the scanner's root assignment.
func InferGroupIdentity(filePath string, libraryType string, assignment RootAssignment) GroupIdentity {
cleanFilePath := filepath.Clean(filePath)
group := GroupIdentity{
GroupKeyVersion: ContentGroupKeyVersion,
ObservedRootPath: deriveObservedRootPath(cleanFilePath, assignment.RootPath),
BaseType: assignment.InferredType,
Confidence: "low",
State: "resolved",
RepresentativePath: cleanFilePath,
}
if group.BaseType == "" {
ctx := ResolvePathContext(cleanFilePath, libraryType)
group.BaseType = "movie"
if ctx != nil && ctx.Type != "" {
group.BaseType = ctx.Type
}
}
if group.BaseType == "series" {
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, &group)
} else {
populateMovieGroupIdentity(cleanFilePath, assignment, &group)
}
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath), group.BaseType); ids != nil {
group.TmdbID = ids.TmdbID
group.ImdbID = ids.ImdbID
group.TvdbID = ids.TvdbID
}
if group.TmdbID == "" || group.ImdbID == "" || group.TvdbID == "" {
if ids := ParseStructuredFolderIDs(strings.TrimSuffix(filepath.Base(cleanFilePath), filepath.Ext(cleanFilePath))); ids != nil {
if group.TmdbID == "" {
group.TmdbID = ids.TmdbID
}
if group.ImdbID == "" {
group.ImdbID = ids.ImdbID
}
if group.TvdbID == "" {
group.TvdbID = ids.TvdbID
}
}
}
if group.ContentGroupKey == "" {
group.ContentGroupKey = isolatedGroupKey(group.BaseType, group.ObservedRootPath, cleanFilePath)
}
return group
}
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, group *GroupIdentity) {
parentDir := filepath.Dir(filePath)
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
parentTitle = StripComparisonSafeEditionSuffix(parentTitle)
baseNoExt := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
stem := parseInferMovieStem(baseNoExt, parentTitle, parentYear)
baseTitle := ""
baseYear := 0
confidence := "low"
state := "resolved"
reasons := []string{}
if parentTrusted && parentTitle != "" {
baseTitle = parentTitle
baseYear = parentYear
confidence = "high"
}
if stem.Title != "" {
switch relation := classifyTitleRelation(parentTitle, stem.Title); {
case parentTrusted && relation == titleRelationEquivalent:
if stem.Year != 0 && baseYear == 0 {
baseYear = stem.Year
}
case parentTrusted && relation == titleRelationSoft:
reasons = append(reasons, "variant_suffix")
case parentTrusted && relation == titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "unrelated_title")
default:
if baseTitle == "" {
baseTitle = StripComparisonSafeEditionSuffix(stem.Title)
baseYear = stem.Year
confidence = stem.Confidence
}
}
}
if baseTitle == "" {
baseTitle = StripComparisonSafeEditionSuffix(assignment.Title)
baseYear = assignment.Year
if baseTitle != "" {
confidence = "medium"
}
}
if baseTitle == "" && parentTitle != "" {
baseTitle = parentTitle
baseYear = parentYear
}
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted {
state = "ambiguous"
reasons = append(reasons, "episode_pattern")
}
group.BaseTitle = baseTitle
group.BaseYear = baseYear
group.Confidence = normalizeIdentityConfidence(confidence)
group.State = state
group.ContentGroupKey = makeContentGroupKey("movie", baseTitle, baseYear, parentDir, filePath)
group.EvidenceJSON, _ = json.Marshal(map[string]any{
"parent_title": parentTitle,
"parent_year": parentYear,
"parent_trusted": parentTrusted,
"stem_title": stem.Title,
"stem_year": stem.Year,
"stem_remainder": stem.Remainder,
"confidence": group.Confidence,
"observed_root": group.ObservedRootPath,
"reasons": reasons,
"has_episode_shape": assignment.HasEpisodePattern,
})
}
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, group *GroupIdentity) {
ctx := ResolvePathContext(filePath, libraryType)
title := assignment.Title
year := assignment.Year
state := "resolved"
reasons := []string{}
if ctx != nil {
if ctx.Title != "" {
title = ctx.Title
}
if ctx.Year != 0 {
year = ctx.Year
}
}
if title == "" {
folderTitle, folderYear, _ := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
title = folderTitle
year = folderYear
}
observedTitle, observedYear, observedTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
if observedTrusted && observedTitle != "" && title != "" {
switch classifyTitleRelation(observedTitle, title) {
case titleRelationSoft:
reasons = append(reasons, "series_alias")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_title_conflict")
}
if year == 0 {
year = observedYear
}
}
if assignment.Title != "" && title != "" {
switch classifyTitleRelation(assignment.Title, title) {
case titleRelationSoft:
reasons = append(reasons, "series_assignment_soft_conflict")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_assignment_conflict")
}
}
confidence := "low"
if title != "" {
confidence = "medium"
}
if ctx != nil && ctx.RootPath != "" && ctx.HasSeasonStructure {
confidence = "high"
}
if assignment.HasEpisodePattern && (assignment.HasSeasonStructure || (ctx != nil && ctx.HasSeasonStructure)) && state != "ambiguous" {
confidence = "high"
}
if title == "" && assignment.HasEpisodePattern {
state = "ambiguous"
reasons = append(reasons, "series_identity_missing")
}
group.BaseTitle = title
group.BaseYear = year
group.Confidence = normalizeIdentityConfidence(confidence)
group.State = state
group.ContentGroupKey = makeContentGroupKey("series", title, year, group.ObservedRootPath, filePath)
group.EvidenceJSON, _ = json.Marshal(map[string]any{
"title": title,
"year": year,
"observed_root": group.ObservedRootPath,
"observed_title": observedTitle,
"observed_year": observedYear,
"observed_trusted": observedTrusted,
"has_season_struct": assignment.HasSeasonStructure,
"has_episode_pattern": assignment.HasEpisodePattern,
"reasons": reasons,
})
}
func deriveObservedRootPath(filePath, candidateRoot string) string {
cleanFilePath := filepath.Clean(filePath)
cleanRoot := filepath.Clean(candidateRoot)
if cleanRoot != "" {
if rel, err := filepath.Rel(cleanRoot, cleanFilePath); err == nil {
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
return cleanRoot
}
}
}
return filepath.Dir(cleanFilePath)
}
func normalizeIdentityConfidence(value string) string {
switch value {
case "high", "medium":
return value
default:
return "low"
}
}
func makeContentGroupKey(contentType, title string, year int, observedRootPath, filePath string) string {
comparable := normalizeGroupComparable(title)
if comparable == "" {
return isolatedGroupKey(contentType, observedRootPath, filePath)
}
return fmt.Sprintf("v%d|%s|%s|%04d", ContentGroupKeyVersion, contentType, comparable, year)
}
func isolatedGroupKey(contentType, observedRootPath, filePath string) string {
seed := filepath.Clean(observedRootPath)
if seed == "" {
seed = filepath.Clean(filePath)
}
hash := fnv.New64a()
_, _ = hash.Write([]byte(seed))
return fmt.Sprintf("v%d|%s|isolated|%x", ContentGroupKeyVersion, contentType, hash.Sum64())
}
func normalizeGroupComparable(title string) string {
tokens := normalizeInferTokens(StripComparisonSafeEditionSuffix(title))
if len(tokens) == 0 {
return ""
}
if len(tokens) >= 4 && looksLikeInitialism(tokens[0], tokens[1:]) {
tokens = tokens[1:]
}
return strings.Join(tokens, " ")
}
func looksLikeInitialism(token string, rest []string) bool {
if len(token) < 2 || len(rest) < 2 {
return false
}
var initials strings.Builder
for _, part := range rest {
if part == "" {
continue
}
initials.WriteByte(part[0])
}
return token == initials.String()
}
type titleRelation string
const (
titleRelationEquivalent titleRelation = "equivalent"
titleRelationSoft titleRelation = "soft"
titleRelationHard titleRelation = "hard"
)
func classifyTitleRelation(left, right string) titleRelation {
if normalizeGroupComparable(left) == "" || normalizeGroupComparable(right) == "" {
return titleRelationEquivalent
}
if normalizeGroupComparable(left) == normalizeGroupComparable(right) {
return titleRelationEquivalent
}
leftTokens := strings.Fields(normalizeGroupComparable(left))
rightTokens := strings.Fields(normalizeGroupComparable(right))
if variantSuffixOnly(leftTokens, rightTokens) || variantSuffixOnly(rightTokens, leftTokens) {
return titleRelationSoft
}
return titleRelationHard
}
func variantSuffixOnly(shorter, longer []string) bool {
if len(shorter) == 0 || len(longer) <= len(shorter) {
return false
}
for i := range shorter {
if shorter[i] != longer[i] {
return false
}
}
for _, token := range longer[len(shorter):] {
if inferEditionTokenKey(token) != "" {
continue
}
switch token {
case "version", "rated", "r", "super", "sized", "aka":
continue
default:
return false
}
}
return true
}