Sprint 1 — Auto-refresh after download:
- New [[mediaserver]] TOML section with kind/url/token/sections
- mediaserver.Refresh() fans out to Plex (partial via section ID auto-mapping
from file path prefix) and Jellyfin/Emby (full library scan)
- Manager.OnFinalized callback wired in daemon to trigger refresh after
organize() completes — keeps engine package free of mediaserver dep
- New unarr mediaserver {setup,list,remove,test} commands
- unarr init wizard offers to configure refresh when a server is detected
Sprint 2 — .strm instant mode (cloud + agent):
- Mode strm-to-library handled in daemon dispatch: writes a one-line .strm
file pointing to the cloud-resolved debrid HTTPS URL, then triggers refresh
- engine.WriteStrm + StrmDestForTask mirror organize()'s naming so Plex/Jellyfin
see the expected folder structure (Movies/Title (Year)/, TV Shows/Show/Season XX/)
- Atomic write (temp + rename) so partial files never get indexed
- Reports completed/failed status to the cloud via existing agent client
335 lines
8.2 KiB
Go
335 lines
8.2 KiB
Go
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 ""
|
|
}
|