* 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>
332 lines
10 KiB
Go
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
|
|
}
|