package cmd import ( "errors" "fmt" "strings" "github.com/charmbracelet/huh" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/mediaserver" ) func newMediaserverCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mediaserver", Aliases: []string{"plex", "jellyfin"}, Short: "Configure Plex / Jellyfin / Emby auto-refresh", Long: `Manage the list of media servers that unarr should refresh after a download finishes. When configured, unarr triggers a partial library refresh on each server right after a download is verified and organised, so the new file shows up in your Plex / Jellyfin / Emby (and therefore on Roku, Apple TV, Fire TV, etc.) within seconds instead of waiting for the next periodic scan.`, } cmd.AddCommand( newMediaserverSetupCmd(), newMediaserverListCmd(), newMediaserverRemoveCmd(), newMediaserverTestCmd(), ) return cmd } func newMediaserverSetupCmd() *cobra.Command { return &cobra.Command{ Use: "setup", Short: "Interactive wizard to add a Plex / Jellyfin / Emby server", RunE: func(cmd *cobra.Command, args []string) error { return runMediaserverSetup() }, } } func newMediaserverListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List configured media servers", RunE: func(cmd *cobra.Command, args []string) error { cfg := loadConfig() if len(cfg.MediaServers) == 0 { fmt.Println("No media servers configured. Run 'unarr mediaserver setup' to add one.") return nil } for i, s := range cfg.MediaServers { fmt.Printf("%d. %s @ %s\n", i+1, strings.ToUpper(s.Kind), s.URL) if len(s.Sections) > 0 { fmt.Printf(" Sections: %v\n", s.Sections) } } return nil }, } } func newMediaserverRemoveCmd() *cobra.Command { return &cobra.Command{ Use: "remove", Short: "Remove a configured media server", RunE: func(cmd *cobra.Command, args []string) error { cfg := loadConfig() if len(cfg.MediaServers) == 0 { fmt.Println("No media servers configured.") return nil } var options []huh.Option[int] for i, s := range cfg.MediaServers { label := fmt.Sprintf("%s @ %s", strings.ToUpper(s.Kind), s.URL) options = append(options, huh.NewOption(label, i)) } var idx int err := huh.NewForm( huh.NewGroup( huh.NewSelect[int](). Title("Which server to remove?"). Options(options...). Value(&idx), ), ).Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { return nil } return err } cfg.MediaServers = append(cfg.MediaServers[:idx], cfg.MediaServers[idx+1:]...) if err := config.Save(cfg, cfgFile); err != nil { return fmt.Errorf("save config: %w", err) } appCfg = cfg color.New(color.FgGreen).Println(" ✓ Removed.") return nil }, } } func newMediaserverTestCmd() *cobra.Command { return &cobra.Command{ Use: "test", Short: "Trigger a refresh on each configured server (sanity check)", RunE: func(cmd *cobra.Command, args []string) error { cfg := loadConfig() if len(cfg.MediaServers) == 0 { fmt.Println("No media servers configured.") return nil } for _, s := range cfg.MediaServers { fmt.Printf("Refreshing %s @ %s ... ", s.Kind, s.URL) mediaserver.Refresh([]mediaserver.ServerConfig{s}, "") } // Refresh fans out goroutines; give them time to log results. fmt.Println("dispatched (errors, if any, are logged).") return nil }, } } // runMediaserverSetup walks the user through adding a single media server. // Auto-detects local Plex/Jellyfin/Emby via port scan and prefills as much // as possible. func runMediaserverSetup() error { if !isTerminal() { return fmt.Errorf("interactive mode requires a terminal") } bold := color.New(color.Bold) cyan := color.New(color.FgCyan) dim := color.New(color.FgHiBlack) green := color.New(color.FgGreen) cfg := loadConfig() fmt.Println() bold.Println(" Add a media server") fmt.Println() dim.Println(" unarr will hit the server's refresh API after each download,") dim.Println(" so new files appear in your library within seconds.") fmt.Println() detected := mediaserver.Detect() // Pick kind var kind string kindOpts := []huh.Option[string]{ huh.NewOption("Plex", "plex"), huh.NewOption("Jellyfin", "jellyfin"), huh.NewOption("Emby", "emby"), } // Default selection: first detected server's kind, lower-cased. if len(detected.Servers) > 0 { kind = strings.ToLower(detected.Servers[0].Name) } else { kind = "plex" } if err := huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title("Server type"). Options(kindOpts...). Value(&kind), )).Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { return nil } return err } // Prefill URL from detection if available url := "" for _, s := range detected.Servers { if strings.EqualFold(s.Name, kind) { url = s.URL break } } if url == "" { url = defaultURLFor(kind) } if err := huh.NewForm(huh.NewGroup( huh.NewInput(). Title("Server URL"). Description("Reachable from this machine — e.g. http://localhost:32400"). Value(&url). Validate(func(s string) error { s = strings.TrimSpace(s) if s == "" { return fmt.Errorf("URL is required") } if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { return fmt.Errorf("must start with http:// or https://") } return nil }), )).Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { return nil } return err } url = strings.TrimRight(strings.TrimSpace(url), "/") // Token entry token := "" if kind == "plex" { // Try local Preferences.xml first (works when Plex runs on same host). if t := mediaserver.LocalPlexToken(); t != "" { cyan.Println(" ✓ Found Plex token in local Preferences.xml") fmt.Println() useLocal := true _ = huh.NewForm(huh.NewGroup( huh.NewConfirm(). Title("Use the auto-detected token?"). Affirmative("Yes"). Negative("No, paste a different one"). Value(&useLocal), )).Run() if useLocal { token = t } } } if token == "" { title := "API key" desc := "" switch kind { case "plex": title = "Plex token" desc = "Get it via Plex web UI → any item → ⋯ → Get Info → View XML → copy ?X-Plex-Token=... from URL" case "jellyfin": title = "Jellyfin API key" desc = "Dashboard → Advanced → API Keys → New API Key" case "emby": title = "Emby API key" desc = "Server Dashboard → API Keys → New Application" } if err := huh.NewForm(huh.NewGroup( huh.NewInput(). Title(title). Description(desc). Value(&token). Validate(func(s string) error { if strings.TrimSpace(s) == "" { return fmt.Errorf("token is required") } return nil }), )).Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { return nil } return err } } token = strings.TrimSpace(token) // Save newServer := mediaserver.ServerConfig{ Kind: kind, URL: url, Token: token, } // Replace if same kind+URL already present, else append replaced := false for i, existing := range cfg.MediaServers { if strings.EqualFold(existing.Kind, kind) && existing.URL == url { cfg.MediaServers[i] = newServer replaced = true break } } if !replaced { cfg.MediaServers = append(cfg.MediaServers, newServer) } if err := config.Save(cfg, cfgFile); err != nil { return fmt.Errorf("save config: %w", err) } appCfg = cfg fmt.Println() if replaced { green.Printf(" ✓ Updated %s @ %s\n", strings.ToUpper(kind), url) } else { green.Printf(" ✓ Added %s @ %s\n", strings.ToUpper(kind), url) } fmt.Println() // Sanity test doTest := true _ = huh.NewForm(huh.NewGroup( huh.NewConfirm(). Title("Trigger a test refresh now?"). Affirmative("Yes"). Negative("Skip"). Value(&doTest), )).Run() if doTest { mediaserver.Refresh([]mediaserver.ServerConfig{newServer}, "") fmt.Println(" Refresh dispatched. If it failed, the error is in the logs.") } return nil } func defaultURLFor(kind string) string { switch strings.ToLower(kind) { case "plex": return "http://localhost:32400" case "jellyfin": return "http://localhost:8096" case "emby": return "http://localhost:8920" } return "" }