feat: add migrate command, media server detection, and debrid auto-config

- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta]
  - Auto-detect instances via Docker, config files, port scan, Prowlarr
  - Import wanted list (monitored+missing movies/series)
  - Import download history and blocklist to avoid re-downloading
  - Extract debrid tokens from *arr download clients
  - Quality profile mapping to preferred_quality config
  - DISTINCT ON PostgreSQL query for optimal torrent selection
  - JSON export with --dry-run --json (text to stderr, JSON to stdout)
- Media server detection (Plex/Jellyfin/Emby) in unarr init
  - Detects library paths and offers them as download directory options
- Debrid auto-configuration in unarr init
  - Scans *arr instances for debrid tokens
  - Validates and saves via API if user confirms
- New preferred_quality setting in config (2160p/1080p/720p)
- Library scan command (unarr scan) with ffprobe metadata extraction
This commit is contained in:
Deivid Soto 2026-03-29 16:54:32 +02:00
parent 0b6c6849b1
commit 677a8fe083
34 changed files with 4766 additions and 22 deletions

View file

@ -280,6 +280,19 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
task.FilePath = finalPath
task.mu.Unlock()
// 4b. Handle upgrade replacement (mode = "upgrade")
if task.ReplacePath != "" {
backupDir := "" // uses default ~/.local/share/unarr/replaced/
if err := replaceFile(task.ReplacePath, finalPath, backupDir); err != nil {
log.Printf("[%s] replace warning: %v (keeping new file at %s)", task.ID[:8], err, finalPath)
} else {
task.mu.Lock()
task.FilePath = task.ReplacePath
task.mu.Unlock()
log.Printf("[%s] upgraded: replaced %s", task.ID[:8], task.ReplacePath)
}
}
// 5. Complete
if method == MethodTorrent && m.cfg.Organize.Enabled {
// Could add seeding here in the future

View file

@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"strings"
"time"
)
var (
@ -136,6 +137,53 @@ func cleanTitle(title string) string {
return t
}
// replaceFile moves the old file to a backup dir, then moves the new file to the old path.
// Used by upgrade downloads to replace an existing file with a better version.
func replaceFile(oldPath, newPath, backupDir string) error {
if _, err := os.Stat(oldPath); err != nil {
return fmt.Errorf("original file not found: %w", err)
}
if backupDir == "" {
home, _ := os.UserHomeDir()
backupDir = filepath.Join(home, ".local", "share", "unarr", "replaced")
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
// Move old file to backup (with timestamp to avoid collisions)
base := filepath.Base(oldPath)
ext := filepath.Ext(base)
nameNoExt := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%d%s", nameNoExt, time.Now().Unix(), ext)
backupPath := filepath.Join(backupDir, backupName)
if err := os.Rename(oldPath, backupPath); err != nil {
// Cross-device: copy + delete
if err := copyFile(oldPath, backupPath); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
os.Remove(oldPath)
}
// Move new file to old path
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
return fmt.Errorf("create target dir: %w", err)
}
if err := os.Rename(newPath, oldPath); err != nil {
// Cross-device: copy + delete
if err := copyFile(newPath, oldPath); err != nil {
// Rollback: restore backup
os.Rename(backupPath, oldPath)
return fmt.Errorf("replace failed: %w", err)
}
os.Remove(newPath)
}
return nil
}
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {

View file

@ -135,15 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
// CORS headers — allow web player from any origin (HTTPS site → localhost)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
// CORS headers — only when browser sends Origin (HTTPS site → localhost)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
reader := ss.provider.NewFileReader(r.Context())

View file

@ -50,6 +50,8 @@ type Task struct {
DirectFileName string // Original filename from direct URL
NzbID string // Pre-resolved NZB ID (usenet)
NzbPassword string // Password for encrypted NZB archives
ReplacePath string // File to replace after download (upgrade mode)
LibraryItemID int // Library item being upgraded
// Runtime state
Status TaskStatus
@ -88,6 +90,8 @@ func NewTaskFromAgent(at agent.Task) *Task {
DirectFileName: at.DirectFileName,
NzbID: at.NzbID,
NzbPassword: at.NzbPassword,
ReplacePath: at.ReplacePath,
LibraryItemID: at.LibraryItemID,
Mode: mode,
Status: StatusClaimed,
ClaimedAt: time.Now(),