248 lines
7.5 KiB
Go
248 lines
7.5 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
|
|
|
|
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
|
|
}
|