unarr/internal/cmd/config_menu.go
Deivid Soto 1e5de874cf feat(stream): cache scan-time thumbnail frames to the .unarr sidecar
Pre-extract the file panel's sample frames (10/30/50/70/90% of runtime, w=320)
during the library scan and write-through any on-demand /thumbnail request into
the hidden ".unarr/<name>.t<sec>w<width>.jpg" sidecar. The /thumbnail handler
serves a fresh sidecar instantly, so the characteristics panel and seekbar
previews stop re-running ffmpeg per request.

- mediainfo.sidecar: ThumbnailCachePath, ReadCachedThumbnail, WriteCachedThumbnail,
  ExtractThumbnailJPEG (mirrors engine.buildThumbnailArgs).
- library.PrewarmSidecars: also enqueues the panel frame positions (kept in
  lockstep with the web's THUMB_FRACTIONS / THUMB_WIDTH) per item with a duration.
- thumbnailHandler: cache-read → hit; miss → extract → write-through.
- config: library.cache_thumbnails (default true) + both cache toggles exposed in
  the interactive 'unarr config' library menu.

Local only by design — frames are the user's own content, never uploaded.
2026-06-02 09:20:00 +02:00

394 lines
11 KiB
Go

package cmd
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/config"
)
var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"}
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config [category]",
Short: "Edit settings interactively",
Long: `Edit unarr settings interactively with a category-based menu.
Categories:
downloads Download directory, method, speed limits, concurrency
organization Auto-sort into Movies / TV Shows folders
library Library scan settings and file deletion permissions
notifications Desktop notifications
device Agent name
region Country and language
connection API URL, API key
advanced Daemon poll & heartbeat intervals
Run without arguments to see the full menu, or specify a category
to jump directly to it.
Config file: ~/.config/unarr/config.toml
Environment variables override config file values:
UNARR_API_KEY API key
UNARR_API_URL API URL
UNARR_COUNTRY Default country code
UNARR_DOWNLOAD_DIR Download directory`,
Example: ` unarr config # Interactive menu
unarr config downloads # Jump to downloads settings
unarr config region # Jump to region settings`,
Args: cobra.MaximumNArgs(1),
ValidArgs: configCategories,
RunE: func(cmd *cobra.Command, args []string) error {
category := ""
if len(args) == 1 {
category = args[0]
}
return runConfigMenu(category)
},
}
return cmd
}
func runConfigMenu(category string) error {
if !isTerminal() {
return fmt.Errorf("interactive config requires a terminal (use UNARR_* env vars instead)")
}
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.FgHiBlack)
cfg := loadConfig()
original := cfg // snapshot for change detection
fmt.Println()
bold.Println(" unarr config")
fmt.Println()
// Direct category access
if category != "" {
if err := runCategory(&cfg, category); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Cancelled.")
return nil
}
return err
}
return saveIfChanged(cfg, original, green, dim)
}
// Menu loop
for {
var choice string
err := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Settings").
Options(
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
huh.NewOption("Library — scan settings & file deletion", "library"),
huh.NewOption("Notifications — desktop notifications", "notifications"),
huh.NewOption("Device — agent name", "device"),
huh.NewOption("Region — country & language", "region"),
huh.NewOption("Connection — API URL & key", "connection"),
huh.NewOption("Advanced — daemon intervals", "advanced"),
huh.NewOption("Done — save & exit", "done"),
).
Value(&choice),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return saveIfChanged(cfg, original, green, dim)
}
return err
}
if choice == "done" {
return saveIfChanged(cfg, original, green, dim)
}
if err := runCategory(&cfg, choice); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
continue // back to menu
}
return err
}
}
}
func runCategory(cfg *config.Config, category string) error {
switch category {
case "downloads":
return configDownloads(cfg)
case "organization":
return configOrganization(cfg)
case "library":
return configLibrary(cfg)
case "notifications":
return configNotifications(cfg)
case "device":
return configDevice(cfg)
case "region":
return configRegion(cfg)
case "connection":
return configConnection(cfg)
case "advanced":
return configAdvanced(cfg)
default:
return fmt.Errorf("unknown category %q — valid: %s", category, strings.Join(configCategories, ", "))
}
}
func configDownloads(cfg *config.Config) error {
concurrent := strconv.Itoa(cfg.Download.MaxConcurrent)
validConcurrent := map[string]bool{"1": true, "2": true, "3": true, "4": true, "5": true, "6": true, "8": true, "10": true}
if !validConcurrent[concurrent] {
concurrent = "3"
}
validMethods := map[string]bool{"auto": true, "torrent": true, "debrid": true, "usenet": true}
if !validMethods[cfg.Download.PreferredMethod] {
cfg.Download.PreferredMethod = "auto"
}
validQualities := map[string]bool{"": true, "720p": true, "1080p": true, "2160p": true}
if !validQualities[cfg.Download.PreferredQuality] {
cfg.Download.PreferredQuality = ""
}
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Download directory").
Value(&cfg.Download.Dir),
huh.NewSelect[string]().
Title("Preferred method").
Options(
huh.NewOption("Auto (torrent + debrid when available)", "auto"),
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
huh.NewOption("Usenet only (requires Pro)", "usenet"),
).
Value(&cfg.Download.PreferredMethod),
huh.NewSelect[string]().
Title("Preferred quality").
Description("Hint for automatic torrent selection").
Options(
huh.NewOption("Any (best available)", ""),
huh.NewOption("720p", "720p"),
huh.NewOption("1080p", "1080p"),
huh.NewOption("2160p (4K)", "2160p"),
).
Value(&cfg.Download.PreferredQuality),
huh.NewSelect[string]().
Title("Max concurrent downloads").
Options(
huh.NewOption("1", "1"),
huh.NewOption("2", "2"),
huh.NewOption("3 (default)", "3"),
huh.NewOption("4", "4"),
huh.NewOption("5", "5"),
huh.NewOption("6", "6"),
huh.NewOption("8", "8"),
huh.NewOption("10", "10"),
).
Value(&concurrent),
huh.NewInput().
Title("Max download speed").
Description("0 = unlimited. Examples: 10MB, 500KB").
Value(&cfg.Download.MaxDownloadSpeed).
Validate(validateSpeed),
huh.NewInput().
Title("Max upload speed").
Description("0 = unlimited. Examples: 1MB, 500KB").
Value(&cfg.Download.MaxUploadSpeed).
Validate(validateSpeed),
),
).Run()
if err != nil {
return err
}
cfg.Download.Dir = expandHome(strings.TrimSpace(cfg.Download.Dir))
n, _ := strconv.Atoi(concurrent)
if n > 0 {
cfg.Download.MaxConcurrent = n
}
return nil
}
func configOrganization(cfg *config.Config) error {
err := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Auto-organize downloads?").
Description("Sort files into Movies and TV Shows subdirectories").
Value(&cfg.Organize.Enabled),
huh.NewInput().
Title("Movies directory").
Value(&cfg.Organize.MoviesDir),
huh.NewInput().
Title("TV Shows directory").
Value(&cfg.Organize.TVShowsDir),
),
).Run()
if err != nil {
return err
}
cfg.Organize.MoviesDir = expandHome(strings.TrimSpace(cfg.Organize.MoviesDir))
cfg.Organize.TVShowsDir = expandHome(strings.TrimSpace(cfg.Organize.TVShowsDir))
return nil
}
func configNotifications(cfg *config.Config) error {
return huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Desktop notifications?").
Description("Show a notification when a download completes").
Value(&cfg.Notifications.Enabled),
),
).Run()
}
func configDevice(cfg *config.Config) error {
dim := color.New(color.FgHiBlack)
if cfg.Agent.ID != "" {
dim.Printf(" Agent ID: %s\n\n", cfg.Agent.ID)
}
return huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Agent name").
Description("Shown in the web dashboard").
Value(&cfg.Agent.Name),
),
).Run()
}
func configRegion(cfg *config.Config) error {
return huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Country").
Description("ISO code for streaming providers (US, ES, DE, GB...)").
Placeholder("US").
Value(&cfg.General.Country),
huh.NewInput().
Title("Locale").
Description("Language for content metadata (en, es, de, fr...)").
Placeholder("en").
Value(&cfg.General.Locale),
),
).Run()
}
func configConnection(cfg *config.Config) error {
keyDesc := "Current: (none)"
if k := cfg.Auth.APIKey; len(k) > 8 {
keyDesc = "Current: " + k[:8] + "..."
}
return huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("API URL").
Value(&cfg.Auth.APIURL),
huh.NewInput().
Title("API Key").
Description(keyDesc).
EchoMode(huh.EchoModePassword).
Value(&cfg.Auth.APIKey),
),
).Run()
}
func configLibrary(cfg *config.Config) error {
return huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Allow file deletion from web UI?").
Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered.").
Value(&cfg.Library.AllowDelete),
huh.NewConfirm().
Title("Cache subtitles during scan?").
Description("Extract embedded text subtitles to WebVTT once during the scan and store them\nbeside the media (hidden .unarr dir) so playback subtitles are instant — and huge\nremuxes don't time out extracting on demand. Local only; nothing is uploaded.").
Value(&cfg.Library.CacheSubtitles),
huh.NewConfirm().
Title("Cache thumbnails during scan?").
Description("Pre-extract a few preview frames per file (hidden .unarr dir) so the file panel\nand seekbar previews load instantly. Small optimized JPEGs; local only.").
Value(&cfg.Library.CacheThumbnails),
),
).Run()
}
func configAdvanced(_ *config.Config) error {
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
fmt.Println("No advanced settings to configure. Sync intervals are automatic.")
return nil
}
// ── Validators ──────────────────────────────────────────────────────
func validateSpeed(s string) error {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return nil
}
if _, err := config.ParseSpeed(s); err != nil {
return fmt.Errorf("invalid speed: %s (e.g. 10MB, 500KB, 0)", s)
}
return nil
}
func validateDuration(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration: %s (e.g. 30s, 1m, 5m)", s)
}
return nil
}
// ── Save logic ──────────────────────────────────────────────────────
func saveIfChanged(cfg, original config.Config, green, dim *color.Color) error {
if reflect.DeepEqual(cfg, original) {
dim.Println(" No changes made.")
fmt.Println()
return nil
}
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg // update cached config so subsequent calls see the new values
fmt.Println()
green.Printf(" ✓ Configuration saved to %s\n", configPath)
fmt.Println()
return nil
}