stremio/node/media-node/internal/scanner/parser.go
Jos Vooges | STH 84e09bdf36 Fix Plex-style episode parsing and add OMDb scan matching plus TV audio passthrough.
Recognize S10E01 Title.mkv under Season folders, fall back to OMDb when TMDB misses, and let Android TV choose PCM vs HDMI passthrough per device.
2026-09-17 01:39:25 +02:00

289 lines
7.7 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|UHD)\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
}
// Plex: Show/Season 10/S10E01 The Night Owl.mkv
if seas, ep, ok := parseLeadingSeasonEpisode(base); ok {
season := seasonFromDir
if season <= 0 {
season = seas
}
title := showFromDir
if title == "" && seasonFromDir <= 0 {
title = cleanTitle(stripYearParen(parent))
}
if title != "" && season > 0 && ep > 0 {
result := map[string]interface{}{
"title": title,
"season": season,
"episode": ep,
}
attachTags(result, base)
return result
}
}
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
}
// parseLeadingSeasonEpisode: "S10E01 The Night Owl" (geen show-naam vóór SxxExx).
func parseLeadingSeasonEpisode(base string) (season, episode int, ok bool) {
re := regexp.MustCompile(`(?i)^S(\d{1,2})E(\d{1,3})(?:$|[\.\s_-])`)
m := re.FindStringSubmatch(base)
if len(m) < 3 {
return 0, 0, false
}
season = atoi(m[1])
episode = atoi(m[2])
if season <= 0 || episode <= 0 {
return 0, 0, false
}
return season, episode, true
}
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 {
res := strings.ToUpper(m[1])
if res == "UHD" || res == "4K" {
result["resolution"] = "2160p"
} else {
result["resolution"] = m[1]
}
} else if strings.Contains(strings.ToUpper(base), "UHD") {
result["resolution"] = "2160p"
}
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, "-", " ")
tagRe := regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|WEB-?DL|WEBRip|HDTV|DVDRip|REMUX|PROPER|REPACK|HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD|TrueHD(\s*Atmos)?|DTS-?HD|DTS|AAC|AC-?3|EAC3|Atmos|HDR10?\+?|Dolby\s*Vision|DV)\b`)
s = tagRe.ReplaceAllString(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
}