Parse Plex-style Title (Year) and Show/Season folders; restore notWebReady with direct node stream URLs; hide unavailable titles by default. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
6.3 KiB
Go
244 lines
6.3 KiB
Go
package scanner
|
|
|
|
import (
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
yearSepRe = regexp.MustCompile(`[\.\s_-]((19|20)\d{2})[\.\s_-]`)
|
|
yearParenRe = regexp.MustCompile(`(?i)^(.+?)\s*\(((?:19|20)\d{2})\)\s*(?:\[.*\])?$`)
|
|
yearEndRe = regexp.MustCompile(`^(.+?)[\.\s_-]((?:19|20)\d{2})$`)
|
|
resolutionRe = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K)\b`)
|
|
sourceRe = regexp.MustCompile(`(?i)\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b`)
|
|
releaseRe = regexp.MustCompile(`(?i)\b(REMUX|PROPER|REPACK)\b`)
|
|
videoRe = regexp.MustCompile(`(?i)\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b`)
|
|
audioRe = regexp.MustCompile(`(?i)\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b`)
|
|
seriesRe1 = regexp.MustCompile(`(?i)(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})`)
|
|
seriesRe2 = regexp.MustCompile(`(?i)(.+?)[\.\s_-](\d{1,2})x(\d{1,2})`)
|
|
seasonDirRe = regexp.MustCompile(`(?i)^(?:season|seizoen)\s*0*(\d{1,2})$|^s(\d{1,2})$`)
|
|
episodeOnly = regexp.MustCompile(`(?i)(?:^|[\.\s_-])(?:e|ep|episode)[\.\s_-]?0*(\d{1,3})(?:[\.\s_-]|$)`)
|
|
junkNameRe = regexp.MustCompile(`(?i)\b(sample|trailer|preview|featurette|extras?)\b`)
|
|
)
|
|
|
|
func parseMovie(base string) map[string]interface{} {
|
|
title, year := extractMovieTitleYear(base)
|
|
if title == "" {
|
|
return nil
|
|
}
|
|
result := map[string]interface{}{"title": title}
|
|
if year > 0 {
|
|
result["year"] = year
|
|
}
|
|
attachTags(result, base)
|
|
return result
|
|
}
|
|
|
|
func parseSeries(base string) map[string]interface{} {
|
|
for _, re := range []*regexp.Regexp{seriesRe1, seriesRe2} {
|
|
m := re.FindStringSubmatch(base)
|
|
if len(m) >= 4 {
|
|
title := cleanTitle(m[1])
|
|
season := atoi(m[2])
|
|
episode := atoi(m[3])
|
|
if title == "" || season <= 0 || episode <= 0 {
|
|
continue
|
|
}
|
|
result := map[string]interface{}{
|
|
"title": title,
|
|
"season": season,
|
|
"episode": episode,
|
|
}
|
|
attachTags(result, base)
|
|
return result
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseMovieFromPath prefers Plex-style "Title (Year)/file.mkv" folders.
|
|
func parseMovieFromPath(path string) map[string]interface{} {
|
|
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
if isJunkName(base) {
|
|
return nil
|
|
}
|
|
parent := filepath.Base(filepath.Dir(path))
|
|
|
|
fromFile := parseMovie(base)
|
|
fromFolder := parseMovieFolder(parent)
|
|
|
|
if fromFolder == nil && fromFile == nil {
|
|
return nil
|
|
}
|
|
if fromFolder == nil {
|
|
return fromFile
|
|
}
|
|
if fromFile == nil {
|
|
return fromFolder
|
|
}
|
|
|
|
// Folder title/year are authoritative for library layout; keep quality tags from file.
|
|
merged := map[string]interface{}{}
|
|
for k, v := range fromFile {
|
|
merged[k] = v
|
|
}
|
|
merged["title"] = fromFolder["title"]
|
|
if y, ok := fromFolder["year"]; ok {
|
|
merged["year"] = y
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// parseSeriesFromPath supports "Show/Season 01/..." and classic SxxExx filenames.
|
|
func parseSeriesFromPath(path string) map[string]interface{} {
|
|
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
if isJunkName(base) {
|
|
return nil
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
parent := filepath.Base(dir)
|
|
grandparent := filepath.Base(filepath.Dir(dir))
|
|
|
|
seasonFromDir := parseSeasonFolder(parent)
|
|
showFromDir := ""
|
|
if seasonFromDir > 0 {
|
|
showFromDir = cleanTitle(stripYearParen(grandparent))
|
|
}
|
|
|
|
fromFile := parseSeries(base)
|
|
if fromFile != nil {
|
|
if showFromDir != "" {
|
|
fromFile["title"] = showFromDir
|
|
}
|
|
if seasonFromDir > 0 {
|
|
fromFile["season"] = seasonFromDir
|
|
}
|
|
return fromFile
|
|
}
|
|
|
|
episode := parseEpisodeNumber(base)
|
|
season := seasonFromDir
|
|
title := showFromDir
|
|
|
|
// Flat: Show Name/S01E02.mkv (no season folder)
|
|
if title == "" {
|
|
title = cleanTitle(stripYearParen(parent))
|
|
}
|
|
if season <= 0 && fromFile == nil {
|
|
// Try SxxExx-less season in parent already handled; leave 0
|
|
}
|
|
|
|
if title == "" || season <= 0 || episode <= 0 {
|
|
return nil
|
|
}
|
|
result := map[string]interface{}{
|
|
"title": title,
|
|
"season": season,
|
|
"episode": episode,
|
|
}
|
|
attachTags(result, base)
|
|
return result
|
|
}
|
|
|
|
func parseMovieFolder(name string) map[string]interface{} {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name == "." || name == string(filepath.Separator) {
|
|
return nil
|
|
}
|
|
// Skip generic root-ish folder names
|
|
lower := strings.ToLower(name)
|
|
if lower == "movies" || lower == "films" || lower == "movie" || lower == "film" ||
|
|
lower == "media" || lower == "video" || lower == "videos" {
|
|
return nil
|
|
}
|
|
return parseMovie(name)
|
|
}
|
|
|
|
func extractMovieTitleYear(base string) (string, int) {
|
|
if m := yearParenRe.FindStringSubmatch(base); len(m) >= 3 {
|
|
return cleanTitle(m[1]), atoi(m[2])
|
|
}
|
|
if yearMatch := yearSepRe.FindStringSubmatchIndex(base); len(yearMatch) >= 4 {
|
|
return cleanTitle(base[:yearMatch[0]]), atoi(base[yearMatch[2]:yearMatch[3]])
|
|
}
|
|
if m := yearEndRe.FindStringSubmatch(base); len(m) >= 3 {
|
|
return cleanTitle(m[1]), atoi(m[2])
|
|
}
|
|
title := cleanTitle(base)
|
|
return title, 0
|
|
}
|
|
|
|
func parseSeasonFolder(name string) int {
|
|
m := seasonDirRe.FindStringSubmatch(strings.TrimSpace(name))
|
|
if len(m) == 0 {
|
|
return 0
|
|
}
|
|
if m[1] != "" {
|
|
return atoi(m[1])
|
|
}
|
|
return atoi(m[2])
|
|
}
|
|
|
|
func parseEpisodeNumber(base string) int {
|
|
if m := episodeOnly.FindStringSubmatch(base); len(m) >= 2 {
|
|
return atoi(m[1])
|
|
}
|
|
// Bare leading number: "01 - Pilot" / "1. Pilot"
|
|
re := regexp.MustCompile(`^0*(\d{1,3})(?:[\.\s_-]|$)`)
|
|
if m := re.FindStringSubmatch(base); len(m) >= 2 {
|
|
n := atoi(m[1])
|
|
if n > 0 && n < 1000 {
|
|
return n
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func stripYearParen(s string) string {
|
|
if m := yearParenRe.FindStringSubmatch(s); len(m) >= 2 {
|
|
return m[1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func attachTags(result map[string]interface{}, base string) {
|
|
if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 {
|
|
result["resolution"] = m[1]
|
|
}
|
|
if m := sourceRe.FindStringSubmatch(base); len(m) > 1 {
|
|
result["source"] = m[1]
|
|
}
|
|
if m := releaseRe.FindStringSubmatch(base); len(m) > 1 {
|
|
result["releaseType"] = m[1]
|
|
}
|
|
if m := videoRe.FindStringSubmatch(base); len(m) > 1 {
|
|
result["videoCodec"] = m[1]
|
|
}
|
|
if m := audioRe.FindStringSubmatch(base); len(m) > 0 {
|
|
result["audio"] = m[0]
|
|
}
|
|
}
|
|
|
|
func isJunkName(s string) bool {
|
|
return junkNameRe.MatchString(s)
|
|
}
|
|
|
|
func cleanTitle(s string) string {
|
|
s = strings.ReplaceAll(s, ".", " ")
|
|
s = strings.ReplaceAll(s, "_", " ")
|
|
s = strings.ReplaceAll(s, "-", " ")
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func atoi(s string) int {
|
|
n := 0
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return 0
|
|
}
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n
|
|
}
|