272 lines
6.6 KiB
Go
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)
|
|
}
|