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.
289 lines
7.7 KiB
Go
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
|
|
}
|