stremio/node/media-node/internal/importmedia/import.go
Jos Vooges | STH 93bbd07487 Fail loudly on subtitle download and write sidecar with retry (media-node 1.3.2).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:33:00 +02:00

271 lines
6.2 KiB
Go

package importmedia
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var videoExt = map[string]bool{
".mkv": true, ".mp4": true, ".m4v": true,
".avi": true, ".ts": true, ".m2ts": true,
}
var sampleRe = regexp.MustCompile(`(?i)(?:^|[.\-_])(sample|trailer|preview)(?:[.\-_]|$)`)
type SubtitleFile struct {
Language string
Ext string
Content []byte
}
type Result struct {
ImportedPath string
SubtitlePath string
}
type Options struct {
SourcePath string
DestDir string
Title string
Year *int
ReleaseTags string
QualityHint string
Subtitle *SubtitleFile
}
// ImportMovie moves the largest video into destDir as:
//
// Title (Year) - <release tags>.ext
//
// and optionally writes a sidecar subtitle next to it.
func ImportMovie(opts Options) (*Result, error) {
sourcePath := filepath.Clean(strings.TrimSpace(opts.SourcePath))
destDir := filepath.Clean(strings.TrimSpace(opts.DestDir))
if sourcePath == "" || destDir == "" {
return nil, fmt.Errorf("source/dest pad ontbreekt")
}
video, err := findLargestVideo(sourcePath)
if err != nil {
return nil, err
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return nil, fmt.Errorf("doelmap aanmaken: %w", err)
}
ext := strings.ToLower(filepath.Ext(video))
base := sanitizeName(opts.Title)
if opts.Year != nil && *opts.Year > 1900 {
base = fmt.Sprintf("%s (%d)", base, *opts.Year)
}
tags := strings.TrimSpace(opts.ReleaseTags)
if tags == "" {
tags = ExtractReleaseTags(filepath.Base(video), opts.Title, opts.Year)
}
if tags == "" {
tags = strings.TrimSpace(opts.QualityHint)
}
if tags == "" {
tags = "2160p"
}
tags = sanitizeName(tags)
destName := fmt.Sprintf("%s - %s%s", base, tags, ext)
destPath := filepath.Join(destDir, destName)
if err := moveFile(video, destPath); err != nil {
return nil, err
}
result := &Result{ImportedPath: destPath}
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 {
subPath, err := WriteSidecar(destPath, opts.Subtitle)
if err != nil {
return nil, err
}
result.SubtitlePath = subPath
}
cleanupSource(sourcePath, video)
return result, nil
}
// WriteSidecar writes Title.nl.srt next to an existing video file.
func WriteSidecar(videoPath string, sub *SubtitleFile) (string, error) {
if sub == nil || len(sub.Content) == 0 {
return "", fmt.Errorf("ondertitel leeg")
}
videoPath = filepath.Clean(strings.TrimSpace(videoPath))
ext := strings.ToLower(filepath.Ext(videoPath))
if ext == "" {
return "", fmt.Errorf("ongeldig videopad")
}
lang := strings.ToLower(strings.TrimSpace(sub.Language))
if lang == "" {
lang = "nl"
}
subExt := strings.ToLower(strings.TrimSpace(sub.Ext))
if subExt == "" {
subExt = ".srt"
}
if !strings.HasPrefix(subExt, ".") {
subExt = "." + subExt
}
stem := strings.TrimSuffix(filepath.Base(videoPath), ext)
subPath := filepath.Join(filepath.Dir(videoPath), fmt.Sprintf("%s.%s%s", stem, lang, subExt))
if err := os.WriteFile(subPath, sub.Content, 0o644); err != nil {
return "", fmt.Errorf("ondertitel schrijven: %w", err)
}
return subPath, nil
}
// ExtractReleaseTags keeps quality/group tokens from a release name.
func ExtractReleaseTags(release, title string, year *int) string {
s := release
s = regexp.MustCompile(`(?i)\.(mkv|mp4|m4v|avi|ts|m2ts|srt|ass|sub)$`).ReplaceAllString(s, "")
s = strings.ReplaceAll(s, ".", " ")
s = strings.ReplaceAll(s, "_", " ")
for _, w := range strings.Fields(title) {
if len(w) < 2 {
continue
}
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(w) + `\b`)
s = re.ReplaceAllString(s, " ")
}
if year != nil && *year > 1900 {
re := regexp.MustCompile(`\b` + fmt.Sprintf("%d", *year) + `\b`)
s = re.ReplaceAllString(s, " ")
}
return strings.Join(strings.Fields(s), " ")
}
func findLargestVideo(sourcePath string) (string, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return "", fmt.Errorf("bron niet gevonden: %w", err)
}
var best string
var bestSize int64
consider := func(path string, size int64) {
if !videoExt[strings.ToLower(filepath.Ext(path))] {
return
}
name := filepath.Base(path)
if sampleRe.MatchString(name) {
return
}
if size > bestSize {
bestSize = size
best = path
}
}
if !info.IsDir() {
consider(sourcePath, info.Size())
if best == "" {
return "", fmt.Errorf("geen videobestand: %s", sourcePath)
}
return best, 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, fi.Size())
return nil
})
if err != nil {
return "", err
}
if best == "" {
return "", fmt.Errorf("geen videobestand in %s", sourcePath)
}
return best, nil
}
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
_ = os.Remove(dst)
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Remove(src)
}
func cleanupSource(sourcePath, movedVideo string) {
info, err := os.Stat(sourcePath)
if err != nil {
parent := filepath.Dir(sourcePath)
_ = removeEmptyDirs(parent)
return
}
if !info.IsDir() {
_ = os.Remove(sourcePath)
_ = removeEmptyDirs(filepath.Dir(sourcePath))
return
}
_ = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil || fi == nil {
return nil
}
if path == movedVideo {
return nil
}
if !fi.IsDir() {
_ = os.Remove(path)
}
return nil
})
_ = removeEmptyDirs(sourcePath)
}
func removeEmptyDirs(root string) error {
var dirs []string
_ = filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
if err == nil && fi != nil && fi.IsDir() {
dirs = append(dirs, path)
}
return nil
})
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return nil
}
func sanitizeName(s string) string {
s = strings.TrimSpace(s)
replacer := strings.NewReplacer(
"<", "", ">", "", ":", "", `"`, "", "/", "", "\\", "",
"|", "", "?", "", "*", "",
)
s = replacer.Replace(s)
return strings.Join(strings.Fields(s), " ")
}