Files
silo-server/internal/naming/group_identity.go
0fb5afe479 feat(matching): split wrongly merged versions with watch-state reattribution; anchor group keys on provider tags (#319)
* feat(matching): split wrongly merged versions, reattribute watch state, anchor group keys on provider tags

Wrong merges (two titles normalizing to the same title+year key) stacked
different films as fake "versions" of one item with no in-app repair, and
explicit {tmdb-…}/[imdb-…] folder tags could not prevent it because the
content-group key ignored provider IDs entirely. Merges also silently
orphaned all per-user watch state.

- Anchor group keys on structured provider tags: same tag always groups,
  different tags can never merge; untagged files keep title+year keys.
- media_identity_overrides: path-scoped (root/file) forced identities applied
  during group inference, so admin splits survive rescans.
- internal/catalog/reattribute: shared user-state mover — exact moves for
  file-linked rows, evidence-based user_watch_history classification via the
  playback session log, newest-wins progress conflicts; wired into
  rebindItemToExistingItem to stop merge orphaning (with S/E episode mapping).
- POST /admin/items/{id}/split (dry-run = full transaction + rollback, so
  previews are exact), POST /admin/items/{id}/merge, GET /admin/items/{id}/files.
- Web admin: Split Versions dialog (files by folder → candidate search →
  preview → split), Resolve link from ambiguous-roots diagnostics.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reattribute): classify history before moving session log; cover managed downloads and series-scoped preferences

Review findings on #319, all reproduced against a migrated scratch database:

- moveFileSubset re-pointed playback_history_admin before the history
  evidence query ran, erasing exactly the evidence proving a profile's plays
  were all on moved files — their history stayed behind as ambiguous.
  History classification now runs first; the pre-fix code demonstrably fails
  TestRun_HistoryEvidenceClassification.
- Managed offline downloads (downloads.content_id/episode_id) were not
  remapped on split or merge, stranding rows on the old id. Now moved per
  file on splits and swept per id pair on merges/episode re-anchoring.
- Series merges left user_audio_preferences, user_subtitle_preferences,
  user_series_playback_preferences (series_id-keyed) and the denormalized
  user_home_item_dismissals.series_id behind. All four now move, mirroring
  the provider-merge remap.

All five reattribute DB tests now verified green against PostgreSQL, with
new coverage for managed downloads, subtitle preferences, and dismissal
series ids.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:23:32 -04:00

391 lines
12 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
// Overridden marks that an operator identity override was applied to this
// file (see ApplyIdentityOverride).
Overridden bool
}
// 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
}
}
// Explicit structured provider IDs (e.g. {tmdb-694938}) anchor the group's
// identity regardless of how folder and file titles relate, so title
// conflicts must not mark the group ambiguous. Renamed releases inside a
// tagged folder ("Override (2021) {tmdb-694938}" containing "R.I.A.
// (2021).mkv") are the common case.
idAnchored := hasStructuredIDAnchor(cleanFilePath, group.ObservedRootPath)
if group.BaseType == "series" {
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, idAnchored, &group)
} else {
populateMovieGroupIdentity(cleanFilePath, assignment, idAnchored, &group)
}
// ID persistence must mirror hasStructuredIDAnchor: any evidence strong
// enough to anchor identity must also reach downstream matching.
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath)); ids != nil {
group.TmdbID = ids.TmdbID
group.ImdbID = ids.ImdbID
group.TvdbID = ids.TvdbID
}
if group.TmdbID == "" || group.ImdbID == "" || group.TvdbID == "" {
if ids := ParseFolderIDs(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
}
}
}
// Structured provider IDs anchor the group KEY, not just the identity.
// Title+year keys merge distinct titles that normalize identically
// ("Passenger (2026)" vs "The Passenger (2026)" — the article is stripped),
// silently stacking two tagged movies as fake versions of one item. An
// explicit tag is the strongest identity evidence there is, so files tagged
// with the same provider ID always share a group and files tagged apart
// never merge, regardless of how their titles compare.
if anchored := anchoredGroupKey(group.GroupKeyVersion, group.BaseType, group.TmdbID, group.ImdbID, group.TvdbID); anchored != "" {
group.ContentGroupKey = anchored
}
if group.ContentGroupKey == "" {
group.ContentGroupKey = isolatedGroupKey(group.BaseType, group.ObservedRootPath, cleanFilePath)
}
return group
}
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAnchored bool, 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:
if idAnchored {
reasons = append(reasons, "unrelated_title_resolved_by_provider_ids")
} else {
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 && !idAnchored {
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, idAnchored bool, 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:
if idAnchored {
reasons = append(reasons, "series_title_conflict_resolved_by_provider_ids")
} else {
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:
if idAnchored {
reasons = append(reasons, "series_assignment_conflict_resolved_by_provider_ids")
} else {
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 && !idAnchored {
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,
})
}
// hasStructuredIDAnchor reports whether the observed root folder or the file
// name carries explicit provider IDs — structured tags (e.g. {tmdb-694938},
// [tvdbid-81189]) or an unambiguous tt-prefixed IMDb id ("Eggs Run (2021)
// tt8049994"). ParseFolderIDs returns only such explicit evidence, which is
// strong enough to anchor identity over a title conflict.
func hasStructuredIDAnchor(filePath, observedRootPath string) bool {
if ParseFolderIDs(filepath.Base(observedRootPath)) != nil {
return true
}
baseNoExt := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
return ParseFolderIDs(baseNoExt) != nil
}
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
}