feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard (API key, download dir, daemon install). Removed method/name prompts — auto-configured from defaults. - `unarr config [category]`: interactive menu with 7 categories (downloads, organization, notifications, device, region, connection, advanced). Direct access via `unarr config downloads`, etc. - Extract shared helpers (openBrowser, expandHome, isTerminal) to helpers.go. Delete old setup.go and config.go. - Update all "unarr setup" references to "unarr init" across daemon, doctor, status, README, install scripts.
This commit is contained in:
parent
35e5298f23
commit
0b6c6849b1
13 changed files with 541 additions and 248 deletions
|
|
@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
|
||||||
|
- Interactive config menu with 7 categories (`unarr config [category]`)
|
||||||
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
|
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
|
||||||
- Daemon mode with background download management (`unarr start`)
|
- Daemon mode with background download management (`unarr start`)
|
||||||
- One-shot download command (`unarr download`)
|
- One-shot download command (`unarr download`)
|
||||||
- Stream to media player (`unarr stream`)
|
- Stream to media player (`unarr stream`)
|
||||||
- Setup wizard for first-time configuration (`unarr setup`)
|
|
||||||
- Doctor command for diagnostics (`unarr doctor`)
|
- Doctor command for diagnostics (`unarr doctor`)
|
||||||
- Status command for daemon monitoring (`unarr status`)
|
- Status command for daemon monitoring (`unarr status`)
|
||||||
- Download engine with torrent support (debrid and usenet coming soon)
|
- Download engine with torrent support (debrid and usenet coming soon)
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ make build
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Run the setup wizard (opens browser for API key)
|
# 1. Run the init wizard (opens browser for API key)
|
||||||
unarr setup
|
unarr init
|
||||||
|
|
||||||
# 2. Search for content
|
# 2. Search for content
|
||||||
unarr search "breaking bad" --type show --quality 1080p
|
unarr search "breaking bad" --type show --quality 1080p
|
||||||
|
|
@ -69,8 +69,8 @@ unarr start
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `unarr setup` | First-time configuration wizard (API key, download dir, method) |
|
| `unarr init` | First-time configuration wizard (API key, download dir, daemon) |
|
||||||
| `unarr config` | Edit configuration interactively |
|
| `unarr config` | Edit all settings interactively (speed, organization, etc.) |
|
||||||
|
|
||||||
### Search & Discovery
|
### Search & Discovery
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ function Install-Docker {
|
||||||
Write-Host " mkdir `$env:APPDATA\unarr"
|
Write-Host " mkdir `$env:APPDATA\unarr"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " # 2. Run setup (interactive)"
|
Write-Host " # 2. Run setup (interactive)"
|
||||||
Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr setup"
|
Write-Host " docker run -it --rm -v `$env:APPDATA\unarr:/config torrentclaw/unarr init"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " # 3. Start daemon"
|
Write-Host " # 3. Start daemon"
|
||||||
Write-Host " docker run -d --name unarr --restart unless-stopped ``"
|
Write-Host " docker run -d --name unarr --restart unless-stopped ``"
|
||||||
|
|
@ -238,7 +238,7 @@ function Main {
|
||||||
Install-Binary
|
Install-Binary
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " Run " -NoNewline
|
Write-Host " Run " -NoNewline
|
||||||
Write-Host "unarr setup" -ForegroundColor White -NoNewline
|
Write-Host "unarr init" -ForegroundColor White -NoNewline
|
||||||
Write-Host " to get started."
|
Write-Host " to get started."
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ install_docker() {
|
||||||
# 2. Run setup (interactive)
|
# 2. Run setup (interactive)
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
-v ~/.config/unarr:/config \
|
-v ~/.config/unarr:/config \
|
||||||
torrentclaw/unarr setup
|
torrentclaw/unarr init
|
||||||
|
|
||||||
# 3. Start daemon
|
# 3. Start daemon
|
||||||
docker run -d --name unarr \
|
docker run -d --name unarr \
|
||||||
|
|
@ -310,7 +310,7 @@ main() {
|
||||||
case "$METHOD" in
|
case "$METHOD" in
|
||||||
binary)
|
binary)
|
||||||
install_binary
|
install_binary
|
||||||
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
|
printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n"
|
||||||
;;
|
;;
|
||||||
docker)
|
docker)
|
||||||
install_docker
|
install_docker
|
||||||
|
|
@ -322,7 +322,7 @@ main() {
|
||||||
info "Installing via Homebrew..."
|
info "Installing via Homebrew..."
|
||||||
brew install torrentclaw/tap/unarr
|
brew install torrentclaw/tap/unarr
|
||||||
ok "Installed via Homebrew"
|
ok "Installed via Homebrew"
|
||||||
printf "\n Run ${BOLD}unarr setup${RESET} to get started.\n\n"
|
printf "\n Run ${BOLD}unarr init${RESET} to get started.\n\n"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
error "Unknown method: $METHOD"
|
error "Unknown method: $METHOD"
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newConfigCmd() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "config",
|
|
||||||
Short: "Edit configuration interactively",
|
|
||||||
Long: `Edit unarr settings interactively in your terminal.
|
|
||||||
|
|
||||||
Prompts for API URL, API key, and default country. Press Enter to keep
|
|
||||||
the current value. For first-time setup use 'unarr setup' instead.
|
|
||||||
|
|
||||||
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
|
|
||||||
unarr config --config /path/to/config.toml`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runConfig()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func runConfig() error {
|
|
||||||
if !isTerminal() {
|
|
||||||
return fmt.Errorf("interactive config requires a terminal (use --api-key flag or env vars instead)")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
bold := color.New(color.Bold)
|
|
||||||
green := color.New(color.FgGreen)
|
|
||||||
|
|
||||||
cfg := loadConfig()
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
bold.Println(" unarr Configuration")
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// API URL
|
|
||||||
currentURL := cfg.Auth.APIURL
|
|
||||||
fmt.Printf(" API URL [%s]: ", currentURL)
|
|
||||||
apiURL, _ := reader.ReadString('\n')
|
|
||||||
apiURL = strings.TrimSpace(apiURL)
|
|
||||||
if apiURL == "" {
|
|
||||||
apiURL = currentURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Key
|
|
||||||
currentKey := cfg.Auth.APIKey
|
|
||||||
keyDisplay := ""
|
|
||||||
if currentKey != "" {
|
|
||||||
if len(currentKey) > 8 {
|
|
||||||
keyDisplay = currentKey[:8] + "..."
|
|
||||||
} else {
|
|
||||||
keyDisplay = currentKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" API Key [%s]: ", keyDisplay)
|
|
||||||
apiKey, _ := reader.ReadString('\n')
|
|
||||||
apiKey = strings.TrimSpace(apiKey)
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = currentKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Country
|
|
||||||
currentCountry := cfg.General.Country
|
|
||||||
fmt.Printf(" Default country [%s]: ", currentCountry)
|
|
||||||
country, _ := reader.ReadString('\n')
|
|
||||||
country = strings.TrimSpace(country)
|
|
||||||
if country == "" {
|
|
||||||
country = currentCountry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply changes
|
|
||||||
cfg.Auth.APIURL = apiURL
|
|
||||||
cfg.Auth.APIKey = apiKey
|
|
||||||
cfg.General.Country = country
|
|
||||||
|
|
||||||
// Save
|
|
||||||
configPath := config.FilePath()
|
|
||||||
if cfgFile != "" {
|
|
||||||
configPath = cfgFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.Save(cfg, configPath); err != nil {
|
|
||||||
return fmt.Errorf("could not save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
green.Printf(" Configuration saved to %s\n", configPath)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTerminal checks if stdin is a terminal.
|
|
||||||
func isTerminal() bool {
|
|
||||||
fi, err := os.Stdin.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return fi.Mode()&os.ModeCharDevice != 0
|
|
||||||
}
|
|
||||||
367
internal/cmd/config_menu.go
Normal file
367
internal/cmd/config_menu.go
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configCategories = []string{"downloads", "organization", "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
|
||||||
|
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("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 "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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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 configAdvanced(cfg *config.Config) error {
|
||||||
|
return huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Poll interval").
|
||||||
|
Description("How often to check for new tasks (e.g. 30s, 1m)").
|
||||||
|
Value(&cfg.Daemon.PollInterval).
|
||||||
|
Validate(validateDuration),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Heartbeat interval").
|
||||||
|
Description("How often to send heartbeat to server (e.g. 30s, 1m)").
|
||||||
|
Value(&cfg.Daemon.HeartbeatInterval).
|
||||||
|
Validate(validateDuration),
|
||||||
|
),
|
||||||
|
).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ The daemon sends periodic heartbeats and reports download progress back
|
||||||
to the web dashboard. Press Ctrl+C to stop gracefully — active downloads
|
to the web dashboard. Press Ctrl+C to stop gracefully — active downloads
|
||||||
get up to 30 seconds to finish.
|
get up to 30 seconds to finish.
|
||||||
|
|
||||||
Requires: API key, agent ID, and download directory (run 'unarr setup' first).
|
Requires: API key, agent ID, and download directory (run 'unarr init' first).
|
||||||
|
|
||||||
To run as a background service, use 'unarr daemon install' instead.`,
|
To run as a background service, use 'unarr daemon install' instead.`,
|
||||||
Example: ` unarr start
|
Example: ` unarr start
|
||||||
|
|
@ -99,13 +99,13 @@ func runDaemonStart() error {
|
||||||
|
|
||||||
// Validate config
|
// Validate config
|
||||||
if cfg.Auth.APIKey == "" {
|
if cfg.Auth.APIKey == "" {
|
||||||
return fmt.Errorf("no API key configured — run 'unarr setup' first")
|
return fmt.Errorf("no API key configured — run 'unarr init' first")
|
||||||
}
|
}
|
||||||
if cfg.Agent.ID == "" {
|
if cfg.Agent.ID == "" {
|
||||||
return fmt.Errorf("no agent ID — run 'unarr setup' first")
|
return fmt.Errorf("no agent ID — run 'unarr init' first")
|
||||||
}
|
}
|
||||||
if cfg.Download.Dir == "" {
|
if cfg.Download.Dir == "" {
|
||||||
return fmt.Errorf("no download directory — run 'unarr setup' first")
|
return fmt.Errorf("no download directory — run 'unarr init' first")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configured paths are safe
|
// Validate configured paths are safe
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ func runDoctor() error {
|
||||||
path = cfgFile
|
path = cfgFile
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
return path + " (not found, run unarr setup)", fmt.Errorf("missing")
|
return path + " (not found, run unarr init)", fmt.Errorf("missing")
|
||||||
}
|
}
|
||||||
return path, nil
|
return path, nil
|
||||||
})
|
})
|
||||||
|
|
@ -95,7 +95,7 @@ func runDoctor() error {
|
||||||
key = cfg.Auth.APIKey
|
key = cfg.Auth.APIKey
|
||||||
}
|
}
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return "run unarr setup to configure", fmt.Errorf("missing")
|
return "run unarr init to configure", fmt.Errorf("missing")
|
||||||
}
|
}
|
||||||
if len(key) > 8 {
|
if len(key) > 8 {
|
||||||
return key[:8] + "...", nil
|
return key[:8] + "...", nil
|
||||||
|
|
@ -130,7 +130,7 @@ func runDoctor() error {
|
||||||
return "no API key", fmt.Errorf("skipped")
|
return "no API key", fmt.Errorf("skipped")
|
||||||
}
|
}
|
||||||
if cfg.Agent.ID == "" {
|
if cfg.Agent.ID == "" {
|
||||||
return "no agent ID, run unarr setup", fmt.Errorf("not registered")
|
return "no agent ID, run unarr init", fmt.Errorf("not registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
|
ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
|
||||||
|
|
@ -155,7 +155,7 @@ func runDoctor() error {
|
||||||
check("Download directory", func() (string, error) {
|
check("Download directory", func() (string, error) {
|
||||||
dir := cfg.Download.Dir
|
dir := cfg.Download.Dir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return "not configured, run unarr setup", fmt.Errorf("missing")
|
return "not configured, run unarr init", fmt.Errorf("missing")
|
||||||
}
|
}
|
||||||
fi, err := os.Stat(dir)
|
fi, err := os.Stat(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|
|
||||||
56
internal/cmd/helpers.go
Normal file
56
internal/cmd/helpers.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openBrowser opens a URL in the default browser.
|
||||||
|
func openBrowser(url string) {
|
||||||
|
var c *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
c = exec.Command("open", url)
|
||||||
|
case "windows":
|
||||||
|
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||||
|
default: // linux, freebsd
|
||||||
|
c = exec.Command("xdg-open", url)
|
||||||
|
}
|
||||||
|
_ = c.Start() // fire and forget; best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDownloadDir returns a sensible default download directory.
|
||||||
|
func defaultDownloadDir() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(home, "Media"),
|
||||||
|
filepath.Join(home, "Downloads", "unarr"),
|
||||||
|
}
|
||||||
|
for _, d := range candidates {
|
||||||
|
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "Media")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandHome expands a leading ~/ to the user's home directory.
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTerminal checks if stdin is a terminal (not piped).
|
||||||
|
func isTerminal() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fi.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -17,25 +17,25 @@ import (
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSetupCmd() *cobra.Command {
|
func newInitCmd() *cobra.Command {
|
||||||
var apiURL string
|
var apiURL string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "setup",
|
Use: "init",
|
||||||
Short: "First-time configuration wizard",
|
Aliases: []string{"setup"},
|
||||||
Long: `Interactive setup that configures API key, download directory, and
|
Short: "First-time configuration wizard",
|
||||||
preferred download method.
|
Long: `Interactive setup that connects your account, picks a download directory,
|
||||||
|
and optionally installs the daemon as a background service.
|
||||||
|
|
||||||
Opens your browser to create/copy your API key, then walks you through
|
Opens your browser to create/copy your API key, validates it against the
|
||||||
choosing a download directory, method (torrent, debrid, usenet), and
|
server, and saves your configuration.
|
||||||
device name. Validates the API key against the server before saving.
|
|
||||||
|
|
||||||
Run this once after installing unarr. To change settings later,
|
Run this once after installing unarr. To customize settings later,
|
||||||
use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
|
use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
|
||||||
Example: ` unarr setup
|
Example: ` unarr init
|
||||||
unarr setup --api-url https://custom.server.com`,
|
unarr init --api-url https://custom.server.com`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runSetup(apiURL)
|
return runInit(apiURL)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,13 +44,17 @@ use 'unarr config' or edit ~/.config/unarr/config.toml directly.`,
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSetup(apiURLOverride string) error {
|
func runInit(apiURLOverride string) error {
|
||||||
|
if !isTerminal() {
|
||||||
|
return fmt.Errorf("interactive mode requires a terminal (use UNARR_API_KEY env var instead)")
|
||||||
|
}
|
||||||
|
|
||||||
bold := color.New(color.Bold)
|
bold := color.New(color.Bold)
|
||||||
green := color.New(color.FgGreen)
|
green := color.New(color.FgGreen)
|
||||||
cyan := color.New(color.FgCyan)
|
cyan := color.New(color.FgCyan)
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
bold.Println(" unarr Setup")
|
bold.Println(" unarr init")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
|
@ -64,18 +68,18 @@ func runSetup(apiURLOverride string) error {
|
||||||
apiURL = "https://torrentclaw.com"
|
apiURL = "https://torrentclaw.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open browser to API keys page
|
// ── Step 1/3: Connect account ───────────────────────────────────
|
||||||
|
|
||||||
keysURL := apiURL + "/profile?tab=apikey"
|
keysURL := apiURL + "/profile?tab=apikey"
|
||||||
fmt.Printf(" Opening %s ...\n", keysURL)
|
fmt.Printf(" Opening %s ...\n", keysURL)
|
||||||
openBrowser(keysURL)
|
openBrowser(keysURL)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Step 1: API Key
|
|
||||||
apiKey := cfg.Auth.APIKey
|
apiKey := cfg.Auth.APIKey
|
||||||
err := huh.NewForm(
|
err := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("API Key").
|
Title("Step 1/3 — API Key").
|
||||||
Description("Copy it from the page that just opened in your browser").
|
Description("Copy it from the page that just opened in your browser").
|
||||||
Placeholder("tc_...").
|
Placeholder("tc_...").
|
||||||
Value(&apiKey).
|
Value(&apiKey).
|
||||||
|
|
@ -92,6 +96,10 @@ func runSetup(apiURLOverride string) error {
|
||||||
),
|
),
|
||||||
).Run()
|
).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\n Init cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apiKey = strings.TrimSpace(apiKey)
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
|
@ -129,7 +137,8 @@ func runSetup(apiURLOverride string) error {
|
||||||
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Step 2: Download directory
|
// ── Step 2/3: Download directory ────────────────────────────────
|
||||||
|
|
||||||
downloadDir := cfg.Download.Dir
|
downloadDir := cfg.Download.Dir
|
||||||
if downloadDir == "" {
|
if downloadDir == "" {
|
||||||
downloadDir = defaultDownloadDir()
|
downloadDir = defaultDownloadDir()
|
||||||
|
|
@ -137,72 +146,53 @@ func runSetup(apiURLOverride string) error {
|
||||||
err = huh.NewForm(
|
err = huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Download Directory").
|
Title("Step 2/3 — Download Directory").
|
||||||
Description("Where should downloaded files be saved?").
|
Description("Where should downloaded files be saved?").
|
||||||
Value(&downloadDir),
|
Value(&downloadDir),
|
||||||
),
|
),
|
||||||
).Run()
|
).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\n Init cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
downloadDir = expandHome(strings.TrimSpace(downloadDir))
|
downloadDir = expandHome(strings.TrimSpace(downloadDir))
|
||||||
|
|
||||||
// Step 3: Preferred download method
|
// ── Step 3/3: Install daemon ────────────────────────────────────
|
||||||
method := cfg.Download.PreferredMethod
|
|
||||||
if method == "" {
|
|
||||||
method = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
methodOptions := []huh.Option[string]{
|
|
||||||
huh.NewOption("Auto (torrent, debrid when available)", "auto"),
|
|
||||||
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
|
|
||||||
}
|
|
||||||
if resp.Features.Debrid {
|
|
||||||
methodOptions = append(methodOptions,
|
|
||||||
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if resp.Features.Usenet {
|
|
||||||
methodOptions = append(methodOptions,
|
|
||||||
huh.NewOption("Usenet only (requires Pro)", "usenet"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var installDaemon bool
|
||||||
err = huh.NewForm(
|
err = huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[string]().
|
huh.NewConfirm().
|
||||||
Title("Download Method").
|
Title("Step 3/3 — Install background service?").
|
||||||
Description("How do you want to download?").
|
Description("Starts unarr automatically on boot (systemd/launchd)").
|
||||||
Options(methodOptions...).
|
Affirmative("Yes, install and start").
|
||||||
Value(&method),
|
Negative("No, I'll run it manually").
|
||||||
|
Value(&installDaemon),
|
||||||
),
|
),
|
||||||
).Run()
|
).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Println("\n Init cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Agent name
|
// ── Save config ─────────────────────────────────────────────────
|
||||||
err = huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Device Name").
|
|
||||||
Description("A name for this machine (shown in the web dashboard)").
|
|
||||||
Value(&agentName),
|
|
||||||
),
|
|
||||||
).Run()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save config
|
|
||||||
cfg.Auth.APIKey = apiKey
|
cfg.Auth.APIKey = apiKey
|
||||||
cfg.Auth.APIURL = apiURL
|
cfg.Auth.APIURL = apiURL
|
||||||
cfg.Agent.ID = agentID
|
cfg.Agent.ID = agentID
|
||||||
cfg.Agent.Name = strings.TrimSpace(agentName)
|
cfg.Agent.Name = agentName
|
||||||
cfg.Download.Dir = downloadDir
|
cfg.Download.Dir = downloadDir
|
||||||
cfg.Download.PreferredMethod = method
|
|
||||||
|
|
||||||
// Set organize dirs based on download dir
|
if cfg.Download.PreferredMethod == "" {
|
||||||
|
cfg.Download.PreferredMethod = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Organize.MoviesDir == "" {
|
if cfg.Organize.MoviesDir == "" {
|
||||||
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
|
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +200,6 @@ func runSetup(apiURLOverride string) error {
|
||||||
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
|
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate paths before saving
|
|
||||||
if err := cfg.ValidatePaths(); err != nil {
|
if err := cfg.ValidatePaths(); err != nil {
|
||||||
return fmt.Errorf("unsafe configuration: %w", err)
|
return fmt.Errorf("unsafe configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -223,16 +212,27 @@ func runSetup(apiURLOverride string) error {
|
||||||
if err := config.Save(cfg, configPath); err != nil {
|
if err := config.Save(cfg, configPath); err != nil {
|
||||||
return fmt.Errorf("save config: %w", err)
|
return fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
appCfg = cfg // update cached config so subsequent calls see the new values
|
||||||
|
|
||||||
|
// ── Install daemon (if requested) ───────────────────────────────
|
||||||
|
|
||||||
|
if installDaemon {
|
||||||
|
fmt.Println()
|
||||||
|
if err := runDaemonInstall(); err != nil {
|
||||||
|
color.New(color.FgYellow).Printf(" Could not install daemon: %s\n", err)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" You can install it later with: " + bold.Sprint("unarr daemon install"))
|
||||||
|
fmt.Println(" Or run manually with: " + bold.Sprint("unarr start"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Summary
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
green.Println(" Setup complete!")
|
green.Println(" ✓ unarr is ready!")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
fmt.Printf(" Dashboard: %s/downloads\n", apiURL)
|
||||||
fmt.Printf(" Downloads: %s\n", downloadDir)
|
fmt.Printf(" Config: %s\n", configPath)
|
||||||
fmt.Printf(" Method: %s\n", method)
|
|
||||||
fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...")
|
|
||||||
fmt.Printf(" Config: %s\n", configPath)
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Features summary
|
// Features summary
|
||||||
|
|
@ -246,46 +246,21 @@ func runSetup(apiURLOverride string) error {
|
||||||
if resp.Features.Usenet {
|
if resp.Features.Usenet {
|
||||||
features = append(features, "Usenet")
|
features = append(features, "Usenet")
|
||||||
}
|
}
|
||||||
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
|
if len(features) > 0 {
|
||||||
|
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !installDaemon {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Start the daemon:")
|
||||||
|
fmt.Println(" " + bold.Sprint("unarr start") + " foreground (Ctrl+C to stop)")
|
||||||
|
fmt.Println(" " + bold.Sprint("unarr daemon install") + " background service (auto-start on boot)")
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Next: run", bold.Sprint("unarr start"), "to begin downloading")
|
fmt.Println(" Customize speed limits, notifications, and more:")
|
||||||
|
fmt.Println(" " + bold.Sprint("unarr config"))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// openBrowser opens a URL in the default browser.
|
|
||||||
func openBrowser(url string) {
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
cmd = exec.Command("open", url)
|
|
||||||
case "windows":
|
|
||||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
|
||||||
default: // linux, freebsd
|
|
||||||
cmd = exec.Command("xdg-open", url)
|
|
||||||
}
|
|
||||||
cmd.Start() // fire and forget
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultDownloadDir() string {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(home, "Media"),
|
|
||||||
filepath.Join(home, "Downloads", "unarr"),
|
|
||||||
}
|
|
||||||
for _, d := range candidates {
|
|
||||||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(home, "Media")
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandHome(path string) string {
|
|
||||||
if strings.HasPrefix(path, "~/") {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, path[2:])
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
@ -32,7 +32,7 @@ Search 30+ torrent sources, inspect torrent quality, discover popular content,
|
||||||
find streaming providers, and manage your media collection — all from your terminal.
|
find streaming providers, and manage your media collection — all from your terminal.
|
||||||
|
|
||||||
Get started:
|
Get started:
|
||||||
unarr setup First-time configuration wizard
|
unarr init First-time configuration wizard
|
||||||
unarr search "breaking bad" Search for content
|
unarr search "breaking bad" Search for content
|
||||||
unarr start Start the download daemon
|
unarr start Start the download daemon
|
||||||
|
|
||||||
|
|
@ -62,8 +62,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
||||||
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
|
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")
|
||||||
|
|
||||||
// Getting Started
|
// Getting Started
|
||||||
setupCmd := newSetupCmd()
|
initCmd := newInitCmd()
|
||||||
setupCmd.GroupID = "start"
|
initCmd.GroupID = "start"
|
||||||
configCmd := newConfigCmd()
|
configCmd := newConfigCmd()
|
||||||
configCmd.GroupID = "start"
|
configCmd.GroupID = "start"
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`,
|
||||||
|
|
||||||
rootCmd.AddCommand(
|
rootCmd.AddCommand(
|
||||||
// Getting Started
|
// Getting Started
|
||||||
setupCmd,
|
initCmd,
|
||||||
configCmd,
|
configCmd,
|
||||||
// Search & Discovery
|
// Search & Discovery
|
||||||
searchCmd,
|
searchCmd,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func runStatus() error {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
|
||||||
if cfg.Auth.APIKey == "" {
|
if cfg.Auth.APIKey == "" {
|
||||||
dim.Println(" Not configured. Run 'unarr setup' first.")
|
dim.Println(" Not configured. Run 'unarr init' first.")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ type diskFileProvider struct {
|
||||||
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
func (p *diskFileProvider) NewFileReader(_ context.Context) io.ReadSeekCloser {
|
||||||
f, err := os.Open(p.path)
|
f, err := os.Open(p.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("stream: failed to open %q: %v", p.path, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
|
|
@ -134,6 +135,17 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
|
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")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reader := ss.provider.NewFileReader(r.Context())
|
reader := ss.provider.NewFileReader(r.Context())
|
||||||
if reader == nil {
|
if reader == nil {
|
||||||
http.Error(w, "file not found", http.StatusNotFound)
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue