Files
silo-server/internal/scanner/group_inference.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

332 lines
10 KiB
Go

package scanner
import (
"encoding/json"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/naming"
)
type fileGroupAssignment = naming.GroupIdentity
// identityOverrideSet indexes path-scoped identity overrides for per-file
// lookup during inference. File scope wins over root scope; among root
// overrides the deepest matching root wins.
type identityOverrideSet struct {
byFile map[string]*models.MediaIdentityOverride
byRoot map[string]*models.MediaIdentityOverride
}
func newIdentityOverrideSet(overrides []models.MediaIdentityOverride) *identityOverrideSet {
if len(overrides) == 0 {
return nil
}
set := &identityOverrideSet{
byFile: map[string]*models.MediaIdentityOverride{},
byRoot: map[string]*models.MediaIdentityOverride{},
}
for i := range overrides {
override := &overrides[i]
switch override.Scope {
case models.IdentityOverrideScopeFile:
if path := filepath.Clean(override.FilePath); path != "" && path != "." {
set.byFile[path] = override
}
case models.IdentityOverrideScopeRoot:
if path := filepath.Clean(override.RootPath); path != "" && path != "." {
set.byRoot[path] = override
}
}
}
return set
}
func (s *identityOverrideSet) lookup(filePath string) *models.MediaIdentityOverride {
if s == nil {
return nil
}
if override, ok := s.byFile[filePath]; ok {
return override
}
// Walk ancestor directories so the deepest matching root override wins.
for dir := filepath.Dir(filePath); ; dir = filepath.Dir(dir) {
if override, ok := s.byRoot[dir]; ok {
return override
}
parent := filepath.Dir(dir)
if parent == dir {
return nil
}
}
}
type groupInferenceResult struct {
Assignments map[string]fileGroupAssignment
ScannedGroups []models.ScannedMediaGroup
Locations []models.ObservedMediaLocation
GroupLocations []models.MediaGroupLocation
}
func inferGroupAssignments(
filePaths []string,
libraryType string,
folderID int,
rootAssignments map[string]fileRootAssignment,
identityOverrides *identityOverrideSet,
) groupInferenceResult {
assignments := make(map[string]fileGroupAssignment, len(filePaths))
groupBuckets := make(map[string][]fileGroupAssignment)
locationBuckets := make(map[string][]fileGroupAssignment)
for _, rawPath := range filePaths {
cleanPath := filepath.Clean(rawPath)
rootAssignment, ok := rootAssignments[cleanPath]
if !ok {
rootAssignment = fileRootAssignment{
FilePath: cleanPath,
RootPath: filepath.Dir(cleanPath),
InferredType: "movie",
}
}
identity := naming.InferGroupIdentity(cleanPath, libraryType, rootAssignment)
if override := identityOverrides.lookup(cleanPath); override != nil {
naming.ApplyIdentityOverride(&identity, naming.IdentityOverride{
ForcedType: override.ForcedType,
ForcedTitle: override.ForcedTitle,
ForcedYear: override.ForcedYear,
ForcedTmdbID: override.ForcedTmdbID,
ForcedImdbID: override.ForcedImdbID,
ForcedTvdbID: override.ForcedTvdbID,
})
}
assignments[cleanPath] = identity
groupBuckets[groupBucketKey(identity)] = append(groupBuckets[groupBucketKey(identity)], identity)
locationBuckets[identity.ObservedRootPath] = append(locationBuckets[identity.ObservedRootPath], identity)
}
groupKeys := make([]string, 0, len(groupBuckets))
for key := range groupBuckets {
groupKeys = append(groupKeys, key)
}
sort.Strings(groupKeys)
scannedGroups := make([]models.ScannedMediaGroup, 0, len(groupKeys))
groupLocations := make([]models.MediaGroupLocation, 0, len(groupKeys))
for _, key := range groupKeys {
entries := groupBuckets[key]
if len(entries) == 0 {
continue
}
first := entries[0]
scannedGroups = append(scannedGroups, models.ScannedMediaGroup{
MediaFolderID: folderID,
GroupKeyVersion: first.GroupKeyVersion,
ContentGroupKey: first.ContentGroupKey,
State: aggregateGroupState(entries),
InferredType: first.BaseType,
TypeConfidence: first.Confidence,
BaseTitle: first.BaseTitle,
BaseYear: first.BaseYear,
TmdbID: first.TmdbID,
ImdbID: first.ImdbID,
TvdbID: first.TvdbID,
ObservedFileCount: len(entries),
SampleFilePath: first.RepresentativePath,
SampleObservedRootPath: first.ObservedRootPath,
EvidenceJSON: aggregateGroupEvidence(entries),
OverrideSource: aggregateGroupOverrideSource(entries),
})
seenLocations := map[string]bool{}
for i, entry := range entries {
if seenLocations[entry.ObservedRootPath] {
continue
}
seenLocations[entry.ObservedRootPath] = true
groupLocations = append(groupLocations, models.MediaGroupLocation{
MediaFolderID: folderID,
GroupKeyVersion: entry.GroupKeyVersion,
ContentGroupKey: entry.ContentGroupKey,
ObservedRootPath: entry.ObservedRootPath,
IsPrimary: i == 0,
})
}
}
locationKeys := make([]string, 0, len(locationBuckets))
for key := range locationBuckets {
locationKeys = append(locationKeys, key)
}
sort.Strings(locationKeys)
locations := make([]models.ObservedMediaLocation, 0, len(locationKeys))
for _, observedRoot := range locationKeys {
entries := locationBuckets[observedRoot]
if len(entries) == 0 {
continue
}
groupCounts := make(map[string]fileGroupAssignment)
for _, entry := range entries {
groupCounts[groupBucketKey(entry)] = entry
}
primaryGroupVersion := 0
primaryGroupKey := ""
primary := entries[0]
if len(groupCounts) == 1 {
primaryGroupVersion = primary.GroupKeyVersion
primaryGroupKey = primary.ContentGroupKey
}
locationEvidence, _ := json.Marshal(map[string]any{
"observed_root_path": observedRoot,
"group_count": len(groupCounts),
})
locations = append(locations, models.ObservedMediaLocation{
MediaFolderID: folderID,
ObservedRootPath: observedRoot,
LocationType: primary.BaseType,
SampleFilePath: primary.RepresentativePath,
ObservedFileCount: len(entries),
ContentGroupCount: len(groupCounts),
PrimaryGroupKeyVersion: primaryGroupVersion,
PrimaryContentGroupKey: primaryGroupKey,
State: aggregateLocationState(entries),
EvidenceJSON: locationEvidence,
})
}
return groupInferenceResult{
Assignments: assignments,
ScannedGroups: scannedGroups,
Locations: locations,
GroupLocations: groupLocations,
}
}
func groupBucketKey(identity naming.GroupIdentity) string {
return identity.ContentGroupKey + "|" + identity.BaseType
}
func aggregateGroupState(entries []fileGroupAssignment) string {
for _, entry := range entries {
if entry.State == "ambiguous" {
return "ambiguous"
}
}
return "resolved"
}
func aggregateLocationState(entries []fileGroupAssignment) string {
state := "resolved"
seenGroups := map[string]struct{}{}
nonOverriddenGroups := map[string]struct{}{}
for _, entry := range entries {
seenGroups[groupBucketKey(entry)] = struct{}{}
if !entry.Overridden {
nonOverriddenGroups[groupBucketKey(entry)] = struct{}{}
}
if entry.State == "ambiguous" {
state = "ambiguous"
}
}
// A location split across groups is ambiguous only when the split was not
// operator-directed: identity overrides deliberately place files from one
// folder into different groups, and flagging that forever would make every
// resolved split look broken.
if len(seenGroups) > 1 && len(nonOverriddenGroups) > 1 {
state = "ambiguous"
}
return state
}
const (
overrideSourceNone = "none"
overrideSourceManual = "manual"
)
func aggregateGroupOverrideSource(entries []fileGroupAssignment) string {
for _, entry := range entries {
if entry.Overridden {
return overrideSourceManual
}
}
return overrideSourceNone
}
func aggregateGroupEvidence(entries []fileGroupAssignment) []byte {
observedRoots := make([]string, 0, len(entries))
for _, entry := range entries {
observedRoots = append(observedRoots, entry.ObservedRootPath)
}
evidence, _ := json.Marshal(map[string]any{
"observed_roots": observedRoots,
"file_count": len(entries),
})
return evidence
}
func applyGroupOverrides(result *groupInferenceResult, overridesByKey map[string]models.MediaGroupOverride) {
if result == nil || len(overridesByKey) == 0 {
return
}
for i, group := range result.ScannedGroups {
override, ok := overridesByKey[groupOverrideKey(group.GroupKeyVersion, group.ContentGroupKey)]
if !ok {
continue
}
group = applyGroupOverrideToSnapshot(group, override)
result.ScannedGroups[i] = group
}
for i, location := range result.Locations {
override, ok := overridesByKey[groupOverrideKey(location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey)]
if !ok {
continue
}
if forcedType := strings.TrimSpace(override.ForcedType); forcedType != "" {
location.LocationType = forcedType
}
if location.ContentGroupCount <= 1 {
location.State = "resolved"
}
result.Locations[i] = location
}
}
func groupOverrideKey(version int, key string) string {
return fmtGroupOverrideKey(version, key)
}
func fmtGroupOverrideKey(version int, key string) string {
return strings.Join([]string{strconv.Itoa(version), key}, "|")
}
func applyGroupOverrideToSnapshot(group models.ScannedMediaGroup, override models.MediaGroupOverride) models.ScannedMediaGroup {
if forcedType := strings.TrimSpace(override.ForcedType); forcedType != "" {
group.InferredType = forcedType
}
if forcedTitle := strings.TrimSpace(override.ForcedTitle); forcedTitle != "" {
group.BaseTitle = forcedTitle
}
if override.ForcedYear > 0 {
group.BaseYear = override.ForcedYear
}
if forcedTmdbID := strings.TrimSpace(override.ForcedTmdbID); forcedTmdbID != "" {
group.TmdbID = forcedTmdbID
}
if forcedImdbID := strings.TrimSpace(override.ForcedImdbID); forcedImdbID != "" {
group.ImdbID = forcedImdbID
}
if forcedTvdbID := strings.TrimSpace(override.ForcedTvdbID); forcedTvdbID != "" {
group.TvdbID = forcedTvdbID
}
group.TypeConfidence = "high"
group.State = "resolved"
group.OverrideSource = overrideSourceManual
return group
}