Files

332 lines
10 KiB
Go
Raw Permalink Normal View History

2026-05-22 20:26:11 -04:00
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
}
}
}
2026-05-22 20:26:11 -04:00
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,
2026-05-22 20:26:11 -04:00
) 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,
})
}
2026-05-22 20:26:11 -04:00
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),
2026-05-22 20:26:11 -04:00
})
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{}{}
2026-05-22 20:26:11 -04:00
for _, entry := range entries {
seenGroups[groupBucketKey(entry)] = struct{}{}
if !entry.Overridden {
nonOverriddenGroups[groupBucketKey(entry)] = struct{}{}
}
2026-05-22 20:26:11 -04:00
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 {
2026-05-22 20:26:11 -04:00
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
}
2026-05-22 20:26:11 -04:00
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
2026-05-22 20:26:11 -04:00
return group
}