feat: initial commit — unarr CLI

Search, inspect, stream, and download torrents from the terminal.
Replaces the entire *arr stack with a single binary.
This commit is contained in:
Deivid Soto 2026-03-28 11:29:42 +01:00
commit 29cf0a0126
85 changed files with 10178 additions and 0 deletions

129
internal/engine/organize.go Normal file
View file

@ -0,0 +1,129 @@
package engine
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var (
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
)
// OrganizeConfig holds file organization settings.
type OrganizeConfig struct {
Enabled bool
MoviesDir string
TVShowsDir string
}
// organize moves a downloaded file into the proper directory structure.
// Movies: MoviesDir/Title (Year)/filename.ext
// TV: TVShowsDir/Title/Season XX/filename.ext
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" {
return result.FilePath, nil
}
title := task.Title
if title == "" {
title = result.FileName
}
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
seasonRegex.MatchString(result.FileName)
// Detect season for TV
var season string
if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
season = m[1]
isTV = true
}
var destDir string
if isTV && cfg.TVShowsDir != "" {
showName := cleanTitle(title)
destDir = filepath.Join(cfg.TVShowsDir, showName)
if season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if cfg.MoviesDir != "" {
movieName := cleanTitle(title)
year := yearRegex.FindString(title)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", movieName, year))
} else {
destDir = filepath.Join(cfg.MoviesDir, movieName)
}
} else {
return result.FilePath, nil // no organize dirs configured
}
// Validate destination is within the expected base directory
var baseDir string
if isTV && cfg.TVShowsDir != "" {
baseDir = cfg.TVShowsDir
} else {
baseDir = cfg.MoviesDir
}
if !isWithinDir(baseDir, destDir) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err)
}
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err)
}
os.Remove(result.FilePath)
}
return destPath, nil
}
// cleanTitle extracts a clean title from a torrent title string.
func cleanTitle(title string) string {
// Remove year and everything after common separators
t := title
if idx := strings.Index(t, " ("); idx > 0 {
t = t[:idx]
}
// Remove resolution and codec markers
for _, pattern := range []string{"1080p", "720p", "2160p", "480p", "BluRay", "WEB-DL", "HDTV", "x264", "x265", "HEVC"} {
if idx := strings.Index(strings.ToLower(t), strings.ToLower(pattern)); idx > 0 {
t = t[:idx]
}
}
t = strings.TrimRight(t, " .-_")
if t == "" {
return title
}
return t
}
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}