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