* 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>
391 lines
12 KiB
Go
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
|
|
}
|