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) }