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) - .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 { lang := strings.ToLower(strings.TrimSpace(opts.Subtitle.Language)) if lang == "" { lang = "nl" } subExt := strings.ToLower(strings.TrimSpace(opts.Subtitle.Ext)) if subExt == "" { subExt = ".srt" } if !strings.HasPrefix(subExt, ".") { subExt = "." + subExt } stem := strings.TrimSuffix(destName, ext) subPath := filepath.Join(destDir, fmt.Sprintf("%s.%s%s", stem, lang, subExt)) if err := os.WriteFile(subPath, opts.Subtitle.Content, 0o644); err != nil { return nil, fmt.Errorf("ondertitel schrijven: %w", err) } result.SubtitlePath = subPath } cleanupSource(sourcePath, video) return result, 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), " ") }