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 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, ) 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) 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: "none", }) 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{}{} for _, entry := range entries { seenGroups[groupBucketKey(entry)] = struct{}{} if entry.State == "ambiguous" { state = "ambiguous" } } if len(seenGroups) > 1 { state = "ambiguous" } return state } 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 = "manual" return group }