522 lines
16 KiB
Go
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, " "))
|
|
}
|