stremio/node/media-node/internal/scanner/parser.go
Jos Vooges | STH eb8ae1e85a Fix Stremio playback, folder-aware scan, and remove STH branding.
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>
2026-08-25 02:16:46 +02:00

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
}