* fix(scanner): skip misplaced TV episodes in movie libraries
A movie-type library containing a TV show laid out as
"Show/Season NN/SxxExx.mkv" (e.g. a fan "supercuts" pack) created one
bogus movie item per episode, all titled after the season folder
("Season 01"). They surfaced on Recently Added and never matched.
Add naming.IsMisplacedSeriesFile, which detects a file with an SxxExx
name inside an explicit "Season NN"/"Specials" directory, and guard
createOrFindSkeleton so such files in a strict movie library are recorded
as a skipped root (reason series_in_movie_library) instead of becoming
items. The movie match-queue worker now dequeues files the skeleton step
deliberately skips instead of erroring on the empty content id.
The guard fires on the structural signal alone, regardless of any parsed
provider id, because a "Season NN" folder otherwise yields a bogus tmdb
id (the season number). Movies whose release filename merely contains an
SxxExx substring but live in a proper "Title (Year)/" folder are
unaffected (covered by the new test).
* fix(metadata): durably exclude misplaced-series files from movie queue
Address review findings on the misplaced-TV skip:
- The skipped file's content_id is never set, so every library sync
re-enqueued it just for the worker to skip and dequeue it again.
Exclude files beneath a series_in_movie_library skipped root in the
movie match queue predicates; deleting the skipped root row makes the
files eligible again. The worker drain remains to flush rows claimed
before the root was recorded.
- Extract the eligibility predicate (previously duplicated verbatim in
eight queries) into movieQueueFileEligibleCond.
- Derive skipped-root file_count from media_files under the root via a
new UpsertObservedFile instead of hardcoding 1: a 34-episode pack now
reports 34 instead of each per-file upsert overwriting the count.
This also fixes the pre-existing missing_folder_ids undercount.
- Fold the two near-identical Upsert+log blocks in createOrFindSkeleton
into a recordSkippedRoot helper, normalize libraryType once, and name
the reason strings as constants.
- Drop a no-op filepath.Base on already-split path segments in
IsMisplacedSeriesFile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
551 lines
17 KiB
Go
551 lines
17 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?)$`)
|
|
// Matches a well-formed tag ([tvdb-81189]), an unsubstituted Sonarr token
|
|
// ([tvdb-{TvdbId}]), or an empty token ({imdb-}). The id part is either a
|
|
// {...} placeholder or zero-or-more word chars.
|
|
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)); 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)); 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
|
|
}
|
|
|
|
// IsMisplacedSeriesFile reports whether filePath is a TV episode that lives
|
|
// inside an explicit "Season NN/" (or "Specials"/"Extras") directory. Such a
|
|
// file is a series structure dropped into a movie-type library (e.g. a fan
|
|
// "supercuts" pack): a movie library would otherwise turn every episode into a
|
|
// bogus per-episode "Season NN" movie that never matches a movie provider.
|
|
//
|
|
// The check is intentionally strict — it requires BOTH an SxxExx pattern in the
|
|
// file name AND an explicit season/specials ancestor directory — so legitimate
|
|
// movies that merely carry an episode-like substring in their release name
|
|
// (e.g. "...S01E43..." inside a "Title (Year)/" folder) are never flagged.
|
|
func IsMisplacedSeriesFile(filePath string) bool {
|
|
clean := filepath.Clean(filePath)
|
|
baseName := filepath.Base(clean)
|
|
nameNoExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
|
if !inferSeasonEpisodeRe.MatchString(nameNoExt) {
|
|
return false
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(clean), "/")
|
|
for _, part := range parts[:max(len(parts)-1, 0)] {
|
|
if inferSeasonDirRe.MatchString(part) || inferSpecialsDirRe.MatchString(part) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeasonStructure bool) bool {
|
|
if hasSeasonStructure {
|
|
return false
|
|
}
|
|
if ParseFolderIDs(parentBase) != 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) != 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, " "))
|
|
}
|