Files
silo-server/internal/naming/root_inference.go
T

522 lines
16 KiB
Go

package naming
import (
"encoding/json"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
var (
inferTitleYearRe = regexp.MustCompile(`^(.+?)\s*\((\d{4})\)`)
inferWhitespaceTokenRe = regexp.MustCompile(`\s+`)
inferReleaseTokenRe = regexp.MustCompile(`(?i)\b(?:remux|bluray|bdrip|brrip|web[ ._-]?dl|webrip|hdr|dv|2160p|1080p|720p|x264|x265|h\.?264|h\.?265|hevc|av1|aac|dts|truehd|atmos)\b`)
inferSeasonEpisodeRe = regexp.MustCompile(`(?i)[Ss](\d{1,4})[Ee](\d{1,3})`)
inferSeasonDirRe = regexp.MustCompile(`(?i)^Season\s+(\d{1,4})(?:\s.*)?$`)
inferNumericSeasonRe = regexp.MustCompile(`^\d{1,4}$`)
inferSpecialsDirRe = regexp.MustCompile(`(?i)^(?:specials?|extras?)$`)
inferProviderTagRe = regexp.MustCompile(`\s*[{\[](?:tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-[\w]+[}\]]`)
)
type RootAssignment struct {
FilePath string
RootPath string
InferredType string
Title string
Year int
LegacyRootPath string
LegacyType string
HasFolderIDs bool
HasSeasonStructure bool
HasMovieEvidence bool
HasEpisodePattern bool
WrapperCollapsed bool
PromotedAncestor bool
HasStrongContradiction bool
}
func InferRootAssignments(
filePaths []string,
libraryType string,
folderID int,
overrides map[string]models.MediaRootOverride,
) ([]models.ScannedMediaRoot, map[string]RootAssignment) {
assignments := make(map[string]RootAssignment, len(filePaths))
if len(filePaths) == 0 {
return []models.ScannedMediaRoot{}, assignments
}
type aggregate struct {
root models.ScannedMediaRoot
hasFolderIDs bool
wrapperCollapses int
ancestorPromotions int
legacyDisagreements int
seasonEvidenceCount int
episodeEvidenceCount int
movieEvidenceCount int
releaseDensityCount int
movieTypeVotes int
seriesTypeVotes int
contradictionCount int
}
byRoot := make(map[string]*aggregate, len(filePaths))
for _, rawPath := range filePaths {
cleanFilePath := filepath.Clean(rawPath)
assignment := inferFileRootAssignment(cleanFilePath, libraryType, overrides)
assignments[cleanFilePath] = assignment
agg, found := byRoot[assignment.RootPath]
if !found {
agg = &aggregate{
root: models.ScannedMediaRoot{
MediaFolderID: folderID,
RootPath: assignment.RootPath,
State: "resolved",
InferredType: assignment.InferredType,
TypeConfidence: "low",
ObservedFileCount: 0,
SampleFilePath: cleanFilePath,
OverrideSource: "none",
Title: assignment.Title,
Year: assignment.Year,
},
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
agg.hasFolderIDs = true
agg.root.TmdbID = ids.TmdbID
agg.root.ImdbID = ids.ImdbID
agg.root.TvdbID = ids.TvdbID
}
byRoot[assignment.RootPath] = agg
}
agg.root.ObservedFileCount++
agg.hasFolderIDs = agg.hasFolderIDs || assignment.HasFolderIDs
if assignment.WrapperCollapsed {
agg.wrapperCollapses++
}
if assignment.PromotedAncestor {
agg.ancestorPromotions++
}
if assignment.LegacyRootPath != "" && assignment.LegacyRootPath != assignment.RootPath {
agg.legacyDisagreements++
}
if assignment.HasSeasonStructure {
agg.seasonEvidenceCount++
}
if assignment.HasEpisodePattern {
agg.episodeEvidenceCount++
}
if assignment.HasMovieEvidence {
agg.movieEvidenceCount++
}
if assignment.HasStrongContradiction {
agg.contradictionCount++
}
if inferReleaseTokenRe.MatchString(filepath.Base(assignment.RootPath)) {
agg.releaseDensityCount++
}
switch assignment.InferredType {
case "series":
agg.seriesTypeVotes++
default:
agg.movieTypeVotes++
}
if agg.root.Title == "" && assignment.Title != "" {
agg.root.Title = assignment.Title
}
if agg.root.Year == 0 && assignment.Year != 0 {
agg.root.Year = assignment.Year
}
}
roots := make([]string, 0, len(byRoot))
for rootPath := range byRoot {
roots = append(roots, rootPath)
}
sort.Strings(roots)
snapshots := make([]models.ScannedMediaRoot, 0, len(roots))
for _, rootPath := range roots {
agg := byRoot[rootPath]
switch {
case agg.seriesTypeVotes > 0 && agg.movieTypeVotes == 0:
agg.root.InferredType = "series"
case agg.movieTypeVotes > 0 && agg.seriesTypeVotes == 0:
agg.root.InferredType = "movie"
case agg.seasonEvidenceCount > 0 || agg.episodeEvidenceCount > agg.movieEvidenceCount:
agg.root.InferredType = "series"
default:
agg.root.InferredType = "movie"
}
switch {
case agg.hasFolderIDs || agg.seasonEvidenceCount > 0 || agg.movieEvidenceCount > 0:
agg.root.TypeConfidence = "high"
case agg.root.Title != "" || agg.root.Year != 0 || agg.episodeEvidenceCount > 0 || agg.root.ObservedFileCount > 1:
agg.root.TypeConfidence = "medium"
default:
agg.root.TypeConfidence = "low"
}
conflictingTypeVotes := agg.seriesTypeVotes > 0 && agg.movieTypeVotes > 0 &&
(agg.seasonEvidenceCount > 0 || agg.episodeEvidenceCount > 0) &&
agg.movieEvidenceCount > 0
if conflictingTypeVotes ||
(!agg.hasFolderIDs && agg.root.TypeConfidence == "low" && agg.root.Title == "") ||
(agg.contradictionCount > 0 && agg.root.ObservedFileCount == 1) {
agg.root.State = "ambiguous"
}
if override, ok := overrides[rootPath]; ok {
agg.root.OverrideSource = "manual"
agg.root.State = "resolved"
if override.ForcedType != "" {
agg.root.InferredType = override.ForcedType
}
if override.ForcedTitle != "" {
agg.root.Title = override.ForcedTitle
}
if override.ForcedYear != 0 {
agg.root.Year = override.ForcedYear
}
if override.ForcedTmdbID != "" {
agg.root.TmdbID = override.ForcedTmdbID
}
if override.ForcedImdbID != "" {
agg.root.ImdbID = override.ForcedImdbID
}
if override.ForcedTvdbID != "" {
agg.root.TvdbID = override.ForcedTvdbID
}
}
evidence, _ := json.Marshal(map[string]any{
"has_folder_ids": agg.hasFolderIDs,
"season_structure_files": agg.seasonEvidenceCount,
"episode_pattern_files": agg.episodeEvidenceCount,
"movie_evidence_files": agg.movieEvidenceCount,
"release_density_files": agg.releaseDensityCount,
"wrapper_collapses": agg.wrapperCollapses,
"ancestor_promotions": agg.ancestorPromotions,
"legacy_disagreements": agg.legacyDisagreements,
"movie_type_votes": agg.movieTypeVotes,
"series_type_votes": agg.seriesTypeVotes,
"override_applied": agg.root.OverrideSource == "manual",
"observed_file_count": agg.root.ObservedFileCount,
"contradiction_files": agg.contradictionCount,
})
agg.root.EvidenceJSON = evidence
snapshots = append(snapshots, agg.root)
}
return snapshots, assignments
}
func inferFileRootAssignment(
filePath string,
libraryType string,
overrides map[string]models.MediaRootOverride,
) RootAssignment {
assignment := extractPathEvidence(filePath, libraryType)
if overrideRoot, ok := deepestOverrideAncestor(filePath, overrides); ok {
assignment.RootPath = overrideRoot
assignment.PromotedAncestor = overrideRoot != assignment.LegacyRootPath
}
promotedRoot, wrapperCollapsed, promotedAncestor := promoteCandidateRoot(
filePath,
assignment.RootPath,
assignment.Title,
assignment.Year,
)
assignment.RootPath = promotedRoot
assignment.WrapperCollapsed = wrapperCollapsed
assignment.PromotedAncestor = assignment.PromotedAncestor || promotedAncestor
if assignment.Title == "" || assignment.Year == 0 {
rootTitle, rootYear := parseInferTitleYear(filepath.Base(assignment.RootPath))
if assignment.Title == "" {
assignment.Title = rootTitle
}
if assignment.Year == 0 {
assignment.Year = rootYear
}
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
assignment.HasFolderIDs = true
}
return assignment
}
func extractPathEvidence(filePath string, libraryType string) RootAssignment {
cleanFilePath := filepath.Clean(filePath)
baseName := filepath.Base(cleanFilePath)
nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
parentDir := filepath.Dir(cleanFilePath)
parentBase := filepath.Base(parentDir)
pathParts := strings.Split(filepath.ToSlash(cleanFilePath), "/")
dirParts := pathParts[:max(len(pathParts)-1, 0)]
hasEpisodePattern := inferSeasonEpisodeRe.MatchString(nameNoExt)
hasSeasonStructure := detectInferSeasonStructure(dirParts, hasEpisodePattern || normalizeInferLibraryType(libraryType) == "series")
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(parentBase)
fileStem := parseInferMovieStem(nameNoExt, parentTitle, parentYear)
hasMovieEvidence := detectInferMovieFolderEvidence(parentBase, nameNoExt, hasSeasonStructure)
strongMovieContradiction := false
if !hasSeasonStructure && parentTrusted && fileStem.Title != "" && !inferTitlesCoherent(parentTitle, fileStem.Title) {
strongMovieContradiction = true
}
if !hasSeasonStructure && parentTrusted && hasEpisodePattern {
strongMovieContradiction = true
}
inferredType := normalizeInferLibraryType(libraryType)
switch inferredType {
case "series":
inferredType = "series"
case "movie":
inferredType = "movie"
default:
switch {
case hasSeasonStructure:
inferredType = "series"
case hasMovieEvidence:
inferredType = "movie"
case hasEpisodePattern:
inferredType = "series"
default:
inferredType = "movie"
}
}
rootPath := parentDir
if inferredType == "series" {
rootPath = deriveInferSeriesRootPath(cleanFilePath, dirParts)
} else {
rootPath = deriveInferMovieRootPath(cleanFilePath, hasMovieEvidence || parentTrusted)
}
title, year := parseInferTitleYear(filepath.Base(rootPath))
if inferredType == "movie" && parentTrusted {
title = parentTitle
year = parentYear
}
if title == "" || year == 0 {
fileTitle, fileYear := parseInferTitleYear(nameNoExt)
if title == "" {
title = fileTitle
}
if year == 0 {
year = fileYear
}
}
return RootAssignment{
FilePath: cleanFilePath,
RootPath: filepath.Clean(rootPath),
InferredType: inferredType,
Title: title,
Year: year,
LegacyRootPath: filepath.Clean(rootPath),
LegacyType: inferredType,
HasSeasonStructure: hasSeasonStructure,
HasMovieEvidence: hasMovieEvidence,
HasEpisodePattern: hasEpisodePattern,
HasStrongContradiction: strongMovieContradiction,
}
}
func normalizeInferLibraryType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "movie", "movies":
return "movie"
case "series", "tv", "show", "tvshows":
return "series"
default:
return ""
}
}
func detectInferSeasonStructure(parts []string, allowNumeric bool) bool {
for _, part := range parts {
segment := filepath.Base(part)
switch {
case inferSeasonDirRe.MatchString(segment):
return true
case allowNumeric && inferNumericSeasonRe.MatchString(segment):
return true
case inferSpecialsDirRe.MatchString(segment):
return true
}
}
return false
}
func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeasonStructure bool) bool {
if hasSeasonStructure {
return false
}
if ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil {
return true
}
parentTitle, parentYear, trusted := parseInferFolderTitleYear(parentBase)
if parentTitle == "" || (!trusted && parentYear == 0) {
return false
}
fileStem := parseInferMovieStem(nameNoExt, parentTitle, parentYear)
if fileStem.Title == "" {
return false
}
if !inferTitlesCoherent(parentTitle, fileStem.Title) {
return false
}
if parentYear != 0 && fileStem.Year != 0 && parentYear != fileStem.Year {
return true
}
return true
}
func deriveInferSeriesRootPath(filePath string, dirParts []string) string {
if len(dirParts) == 0 {
return filepath.Dir(filePath)
}
for i := len(dirParts) - 1; i >= 0; i-- {
segment := filepath.Base(dirParts[i])
if inferSeasonDirRe.MatchString(segment) || inferNumericSeasonRe.MatchString(segment) || inferSpecialsDirRe.MatchString(segment) {
if i > 0 {
return filepath.Clean(strings.Join(dirParts[:i], string(filepath.Separator)))
}
return filepath.Dir(filePath)
}
}
return filepath.Dir(filePath)
}
func deriveInferMovieRootPath(filePath string, hasMovieEvidence bool) string {
parentDir := filepath.Dir(filePath)
if hasMovieEvidence {
return parentDir
}
baseName := filepath.Base(filePath)
nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
return filepath.Join(parentDir, nameNoExt)
}
func deepestOverrideAncestor(
filePath string,
overrides map[string]models.MediaRootOverride,
) (string, bool) {
if len(overrides) == 0 {
return "", false
}
cleanFilePath := filepath.Clean(filePath)
longest := ""
for rootPath := range overrides {
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanFilePath)
if err != nil {
continue
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
if len(cleanRoot) > len(longest) {
longest = cleanRoot
}
}
if longest == "" {
return "", false
}
return longest, true
}
func promoteCandidateRoot(filePath, currentRoot, title string, year int) (string, bool, bool) {
cleanRoot := filepath.Clean(currentRoot)
wrapperCollapsed := false
promotedAncestor := false
for {
parent := filepath.Dir(cleanRoot)
if parent == "." || parent == "/" || parent == cleanRoot || parent == "" {
break
}
parentBase := filepath.Base(parent)
currentBase := filepath.Base(cleanRoot)
parentIDs := ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil
childTitle, childYear := parseInferTitleYear(currentBase)
parentTitle, parentYear := parseInferTitleYear(parentBase)
sameNamedWrapper := sameInferRootIdentity(parentTitle, parentYear, childTitle, childYear)
matchesParsedTitle := sameInferRootIdentity(parentTitle, parentYear, title, year)
legacySyntheticChild := normalizeInferComparable(currentBase) == normalizeInferComparable(strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)))
switch {
case sameNamedWrapper:
cleanRoot = parent
wrapperCollapsed = true
promotedAncestor = true
continue
case parentIDs && (matchesParsedTitle || legacySyntheticChild):
cleanRoot = parent
promotedAncestor = true
continue
default:
return cleanRoot, wrapperCollapsed, promotedAncestor
}
}
return cleanRoot, wrapperCollapsed, promotedAncestor
}
func sameInferRootIdentity(aTitle string, aYear int, bTitle string, bYear int) bool {
if normalizeInferComparable(aTitle) == "" || normalizeInferComparable(bTitle) == "" {
return false
}
if !inferTitlesCoherent(aTitle, bTitle) {
return false
}
if aYear != 0 && bYear != 0 && aYear != bYear {
return false
}
return true
}
func parseInferTitleYear(name string) (string, int) {
surface := stripInferProviderTags(name)
surface = inferWhitespaceTokenRe.ReplaceAllString(surface, " ")
surface = strings.TrimSpace(surface)
if surface == "" {
return "", 0
}
if title, year, trusted := parseInferFolderTitleYear(surface); trusted {
return title, year
}
if match := inferTitleYearRe.FindStringSubmatch(surface); match != nil {
year := 0
for _, r := range match[2] {
year = year*10 + int(r-'0')
}
return strings.TrimSpace(match[1]), year
}
if stem := parseInferMovieStem(surface, "", 0); stem.Title != "" && stem.Year != 0 {
return stem.Title, stem.Year
}
return surface, 0
}
func stripInferProviderTags(name string) string {
return strings.TrimSpace(inferProviderTagRe.ReplaceAllString(name, " "))
}