stremio/node/media-node/internal/importmedia/series.go
Jos Vooges | STH bd21b01956 Add full series download flow with episode and season import.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 02:30:40 +02:00

272 lines
6.6 KiB
Go

package importmedia
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
var (
seriesSxxExx = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,3})(?:[^a-z0-9]|$)`)
seriesNxNN = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:[^a-z0-9]|$)`)
)
type SeriesEpisodeSpec struct {
EpisodeNumber int
EpisodeTitle string
}
type SeriesEpisodeResult struct {
EpisodeNumber int
ImportedPath string
SubtitlePath string
}
type SeriesResult struct {
// Season directory path
ImportedPath string
Episodes []SeriesEpisodeResult
SubtitlePath string // single-episode subtitle, if any
}
type SeriesOptions struct {
SourcePath string
ShowDir string // …/Show Name (Year)
ShowTitle string
Year *int
SeasonNumber int
Episodes []SeriesEpisodeSpec
ReleaseTags string
QualityHint string
Subtitle *SubtitleFile // only applied when exactly one episode imported
}
// ImportSeries moves episode videos into Show/Season XX/ with scanner-friendly names.
func ImportSeries(opts SeriesOptions) (*SeriesResult, error) {
sourcePath := filepath.Clean(strings.TrimSpace(opts.SourcePath))
showDir := filepath.Clean(strings.TrimSpace(opts.ShowDir))
if sourcePath == "" || showDir == "" {
return nil, fmt.Errorf("source/show pad ontbreekt")
}
if opts.SeasonNumber <= 0 {
return nil, fmt.Errorf("ongeldig seizoen")
}
if len(opts.Episodes) == 0 {
return nil, fmt.Errorf("geen afleveringen opgegeven")
}
wanted := map[int]SeriesEpisodeSpec{}
for _, ep := range opts.Episodes {
if ep.EpisodeNumber > 0 {
wanted[ep.EpisodeNumber] = ep
}
}
if len(wanted) == 0 {
return nil, fmt.Errorf("geen geldige afleveringnummers")
}
videos, err := listVideos(sourcePath)
if err != nil {
return nil, err
}
if len(videos) == 0 {
return nil, fmt.Errorf("geen videobestanden in download")
}
seasonDir := filepath.Join(showDir, fmt.Sprintf("Season %02d", opts.SeasonNumber))
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
return nil, fmt.Errorf("seizoensmap aanmaken: %w", err)
}
// Map episode number → source video path
matched := map[int]string{}
unparsed := []string{}
for _, video := range videos {
base := filepath.Base(video)
epNum := parseEpisodeNumber(base)
if epNum <= 0 {
unparsed = append(unparsed, video)
continue
}
if _, ok := wanted[epNum]; !ok {
continue
}
// Prefer larger file if duplicate episode numbers
if prev, ok := matched[epNum]; ok {
if fileSize(video) <= fileSize(prev) {
continue
}
}
matched[epNum] = video
}
// Single-episode job with one video and no SxxExx in name → accept that file
if len(wanted) == 1 && len(matched) == 0 && len(videos) == 1 {
for num := range wanted {
matched[num] = videos[0]
}
} else if len(wanted) == 1 && len(matched) == 0 && len(unparsed) == 1 && len(videos) == 1 {
for num := range wanted {
matched[num] = unparsed[0]
}
}
if len(matched) == 0 {
return nil, fmt.Errorf("geen afleveringen herkend in download (verwacht S%02dExx in bestandsnaam)", opts.SeasonNumber)
}
showBase := sanitizeName(opts.ShowTitle)
tags := strings.TrimSpace(opts.ReleaseTags)
if tags == "" {
tags = strings.TrimSpace(opts.QualityHint)
}
tags = sanitizeName(tags)
results := make([]SeriesEpisodeResult, 0, len(matched))
moved := make([]string, 0, len(matched))
nums := make([]int, 0, len(matched))
for n := range matched {
nums = append(nums, n)
}
sort.Ints(nums)
for _, epNum := range nums {
src := matched[epNum]
spec := wanted[epNum]
ext := strings.ToLower(filepath.Ext(src))
epCode := fmt.Sprintf("S%02dE%02d", opts.SeasonNumber, epNum)
name := showBase + " - " + epCode
if t := sanitizeName(spec.EpisodeTitle); t != "" {
name += " - " + t
}
if tags != "" {
name += " - " + tags
}
destPath := filepath.Join(seasonDir, name+ext)
if err := moveFile(src, destPath); err != nil {
return nil, fmt.Errorf("verplaatsen E%02d: %w", epNum, err)
}
moved = append(moved, src)
results = append(results, SeriesEpisodeResult{
EpisodeNumber: epNum,
ImportedPath: destPath,
})
}
out := &SeriesResult{
ImportedPath: seasonDir,
Episodes: results,
}
// Single episode + subtitle sidecar
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 && len(results) == 1 {
subPath, err := WriteSidecar(results[0].ImportedPath, opts.Subtitle)
if err != nil {
return nil, err
}
results[0].SubtitlePath = subPath
out.SubtitlePath = subPath
out.Episodes = results
}
cleanupMovedSources(sourcePath, moved)
return out, nil
}
func parseEpisodeNumber(name string) int {
if m := seriesSxxExx.FindStringSubmatch(name); len(m) >= 3 {
return atoi(m[2])
}
if m := seriesNxNN.FindStringSubmatch(name); len(m) >= 3 {
return atoi(m[2])
}
return 0
}
func listVideos(sourcePath string) ([]string, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("bron niet gevonden: %w", err)
}
var out []string
consider := func(path string) {
if !videoExt[strings.ToLower(filepath.Ext(path))] {
return
}
if sampleRe.MatchString(filepath.Base(path)) {
return
}
out = append(out, path)
}
if !info.IsDir() {
consider(sourcePath)
return out, nil
}
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil || fi == nil || fi.IsDir() {
return nil
}
consider(path)
return nil
})
return out, err
}
func fileSize(path string) int64 {
fi, err := os.Stat(path)
if err != nil {
return 0
}
return fi.Size()
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
return n
}
// Remove moved sources and empty dirs; leave unmatched videos alone if still present.
func cleanupMovedSources(sourcePath string, moved []string) {
movedSet := map[string]bool{}
for _, m := range moved {
movedSet[m] = true
}
info, err := os.Stat(sourcePath)
if err != nil {
return
}
if !info.IsDir() {
if movedSet[sourcePath] {
_ = os.Remove(sourcePath)
}
_ = removeEmptyDirs(filepath.Dir(sourcePath))
return
}
// Remove leftover nfo/txt/samples next to moved content, but keep unmatched videos
_ = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil || fi == nil || fi.IsDir() {
return nil
}
if movedSet[path] {
return nil // already moved away
}
base := filepath.Base(path)
ext := strings.ToLower(filepath.Ext(base))
if sampleRe.MatchString(base) || ext == ".nfo" || ext == ".txt" || ext == ".jpg" || ext == ".png" {
_ = os.Remove(path)
}
return nil
})
_ = removeEmptyDirs(sourcePath)
}