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
This commit is contained in:
Deivid Soto 2026-05-05 20:35:08 +02:00
parent 6955b6144b
commit 6adf1e2c4c
13 changed files with 1065 additions and 16 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/mediaserver"
"github.com/torrentclaw/unarr/internal/usenet/download"
)
@ -237,10 +238,28 @@ func runDaemonStart() error {
// Trigger immediate sync when a download slot frees up
manager.OnTaskDone = func() { d.TriggerSync() }
// Trigger Plex/Jellyfin/Emby library refresh after a task finalises so
// the new file appears in the user's library within seconds (instead
// of waiting for the next periodic scan). No-op if no servers
// configured. Errors are logged inside Refresh and never propagate.
if len(cfg.MediaServers) > 0 {
manager.OnFinalized = func(task *engine.Task) {
if task == nil {
return
}
fp := task.SafeFilePath()
if fp == "" {
return
}
mediaserver.Refresh(cfg.MediaServers, fp)
}
}
// Wire: sync receives new tasks → submit to manager or handle stream
d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks {
if t.Mode == "stream" {
switch t.Mode {
case "stream":
if isStreamingTask(t.ID) {
continue
}
@ -251,7 +270,9 @@ func runDaemonStart() error {
streamRegistry.cancels[t.ID] = streamCancel
streamRegistry.mu.Unlock()
go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv)
} else {
case "strm-to-library":
go handleStrmToLibrary(ctx, t, cfg, agentClient)
default:
manager.Submit(ctx, t)
}
}

View file

@ -296,6 +296,34 @@ func runInit(apiURLOverride string) error {
}
}
// ── Plex / Jellyfin / Emby refresh hook ────────────────────────
// Offer to wire library refreshes if a media server was detected and
// none are configured yet. Skipping here is fine — the user can run
// `unarr mediaserver setup` later.
if len(detected.Servers) > 0 && len(cfg.MediaServers) == 0 {
fmt.Println()
var configureMS bool
err = huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Auto-refresh %s on every download?", detected.Servers[0].Name)).
Description("New downloads appear on Roku/Apple TV/etc. within seconds, instead of waiting for the next periodic library scan").
Affirmative("Yes, configure").
Negative("Skip (do it later with 'unarr mediaserver setup')").
Value(&configureMS),
),
).Run()
if err == nil && configureMS {
fmt.Println()
if err := runMediaserverSetup(); err != nil {
color.New(color.FgYellow).Printf(" Media server setup failed: %s\n", err)
} else {
// runMediaserverSetup already saved + updated appCfg.
cfg = appCfg
}
}
}
// ── Debrid auto-detection from *arr ─────────────────────────────
if resp.User.IsPro {

335
internal/cmd/mediaserver.go Normal file
View file

@ -0,0 +1,335 @@
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 ""
}

View file

@ -117,6 +117,9 @@ Source: https://github.com/torrentclaw/unarr`,
scanCmd := newScanCmd()
scanCmd.GroupID = "search"
mediaserverCmd := newMediaserverCmd()
mediaserverCmd.GroupID = "start"
rootCmd.AddCommand(
// Getting Started
initCmd,
@ -146,6 +149,7 @@ Source: https://github.com/torrentclaw/unarr`,
completionCmd,
// Library
scanCmd,
mediaserverCmd,
// Alias: upgrade → self-update
newUpgradeCmd(),
)

View file

@ -0,0 +1,70 @@
package cmd
import (
"context"
"log"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/mediaserver"
)
// handleStrmToLibrary processes a Mode="strm-to-library" task by writing a
// one-line .strm file to the user's media library and triggering a
// Plex/Jellyfin/Emby refresh. No actual download happens; the .strm points
// at the cloud-resolved debrid HTTPS URL, and the media server streams from
// there when the user presses play.
//
// Reports completion (or failure) back to the cloud via the agent client.
func handleStrmToLibrary(ctx context.Context, t agent.Task, cfg config.Config, agentClient *agent.Client) {
short := agent.ShortID(t.ID)
if t.DirectURL == "" {
log.Printf("[%s] strm-to-library: missing directUrl from server", short)
reportStrmFailure(ctx, agentClient, t.ID, "missing directUrl")
return
}
organizeCfg := engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
OutputDir: cfg.Download.Dir,
}
finalPath, err := engine.WriteStrm(t, organizeCfg)
if err != nil {
log.Printf("[%s] strm-to-library write failed: %v", short, err)
reportStrmFailure(ctx, agentClient, t.ID, err.Error())
return
}
log.Printf("[%s] strm-to-library wrote %s", short, finalPath)
// Trigger media-server refresh if any are configured. Errors are logged
// inside Refresh and never propagate — the .strm is on disk, so the
// next periodic scan would pick it up regardless.
if len(cfg.MediaServers) > 0 {
mediaserver.Refresh(cfg.MediaServers, finalPath)
}
if _, reportErr := agentClient.ReportStatus(ctx, agent.StatusUpdate{
TaskID: t.ID,
Status: "completed",
Progress: 100,
FilePath: finalPath,
}); reportErr != nil {
log.Printf("[%s] strm-to-library: status report failed: %v", short, reportErr)
}
}
func reportStrmFailure(ctx context.Context, agentClient *agent.Client, taskID, msg string) {
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
TaskID: taskID,
Status: "failed",
ErrorMessage: msg,
}); err != nil {
log.Printf("[%s] strm failure report failed: %v", agent.ShortID(taskID), err)
}
}