unarr/internal/cmd/mediaserver.go
Deivid Soto 6adf1e2c4c feat(mediaserver): Plex/Jellyfin/Emby auto-refresh + .strm instant mode
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
2026-05-05 20:35:08 +02:00

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 ""
}