diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0ffcc..593c09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,85 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.9-beta] - 2026-06-10 + + +### Changed + +- **daemon**: revisión crítica del reporte de errores de sesión + +### Fixed + +- **daemon**: reportar fallos de arranque de sesión a la web + scan en sesión única +## [1.0.8-beta] - 2026-06-10 + + +### Added + +- **hls**: resume-aware first spawn + capped-CRF/CQ rate control +- **subtitles**: subtitle-fetch jobs vía sync + auto-fetch opcional en scan + +### Fixed + +- **hls**: forced-idr en NVENC/QSV — los segmentos ignoraban force_key_frames +- **hls**: los prewarms ya no desalojan la sesión del espectador + trickplay 12x + +### Other + +- **release**: 1.0.8-beta +## [1.0.7-beta] - 2026-06-08 + + +### Added + +- **subs**: resilient subtitle extraction — sidecars, charset, torrent/debrid + +### Other + +- **release**: 1.0.7-beta +## [1.0.6-beta] - 2026-06-07 + + +### Added + +- **agent**: per-machine key handoff + revocation handling + +### Fixed + +- **agent**: only treat explicit 410/403 as revocation; honour --config + +### Other + +- **release**: 1.0.6-beta +## [1.0.5-beta] - 2026-06-07 + + +### Added + +- **stream**: live transcode telemetry from ffmpeg speed= + +### Documentation + +- **docker**: explain why GPU Vulkan tonemap can't init in-container + +### Fixed + +- **docker**: derive bundled dep arch from dpkg, not TARGETARCH default +- **torrent**: suppress noisy UPnP AddPortMapping warnings + +### Other + +- **release**: 1.0.5-beta ## [1.0.4-beta] - 2026-06-04 ### Fixed - **stream**: self-heal host→container path skew in HLS + sidecar handlers + +### Other + +- **release**: 1.0.4-beta ## [1.0.3-beta] - 2026-06-04 @@ -697,6 +770,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Build - add -s -w -trimpath to Makefile, add build-small target with UPX +[1.0.9-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.8-beta...v1.0.9-beta +[1.0.8-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.7-beta...v1.0.8-beta +[1.0.7-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.6-beta...v1.0.7-beta +[1.0.6-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.5-beta...v1.0.6-beta +[1.0.5-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.4-beta...v1.0.5-beta [1.0.4-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.3-beta...v1.0.4-beta [1.0.3-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.2-beta...v1.0.3-beta [1.0.2-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.1-beta...v1.0.2-beta diff --git a/Dockerfile b/Dockerfile index 3707b62..2a70222 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,13 +41,32 @@ FROM debian:bookworm-slim # its ICD. ~150 KB. The agent only USES libplacebo after a functional # probe (FFmpegSupportsLibplacebo) succeeds AND a real HW encoder is # present, so this is inert on hosts without a working Vulkan GPU. +# +# NOTE: in this container libplacebo's Vulkan probe ALWAYS fails and the +# agent falls back to the CPU zscale tonemap chain — by design, not a +# bug. The nvidia Vulkan ICD is libGLX_nvidia.so.0, whose GL backend +# (libnvidia-glcore) references glibc malloc hooks removed in glibc 2.34 +# (__malloc_hook/__free_hook/...) and the Xorg symbol ErrorF; on a +# headless modern-glibc base (debian or ubuntu) those go unresolved so +# vkCreateInstance returns VK_ERROR_INCOMPATIBLE_DRIVER. We deliberately +# do NOT chase it (would need `graphics` cap + X11 libs + a 1.4 loader +# AND a desktop-class glibc/Xorg — fragile, distro+driver coupled). The +# loader stays so that on the RARE host where Vulkan does come up the +# probe can use it. nvenc/nvdec (CUDA, not Vulkan) work regardless. +# GPU HDR tonemap is a bare-metal-binary feature, not a container one. RUN apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates tzdata wget xz-utils par2 p7zip-full libvulkan1 && \ rm -rf /var/lib/apt/lists/* -# TARGETARCH is set automatically by Docker buildx during cross-builds. -ARG TARGETARCH=amd64 +# Arch for the bundled deps below is taken from `dpkg --print-architecture` (the +# real arch of THIS runtime stage), NOT the TARGETARCH build-arg. A baked +# `ARG TARGETARCH=amd64` default used to shadow buildx's per-leg value in this +# stage, so even the published arm64 image bundled an amd64 cloudflared/ffmpeg +# while the unarr binary was native arm64 → "exec format error" when the daemon +# spawned cloudflared → funnel never came up → TV/Stremio connect failed +# ("Failed to get add-on manifest"). dpkg reads the emulated base image's arch, +# so it is correct under buildx cross-builds AND a plain `docker build`. # Static GPL ffmpeg + ffprobe with nvenc compiled in (BtbN builds). nvenc is # linked but the actual libnvidia-encode.so is dlopen'd at runtime from the @@ -55,10 +74,11 @@ ARG TARGETARCH=amd64 # when a GPU is present and falls back to libx264 when it isn't. Placed in # /usr/local/bin so ResolveFFmpeg picks them up off PATH ahead of any distro # ffmpeg. arm64 has no nvenc but the build still serves software transcode. -RUN case "$TARGETARCH" in \ +RUN ARCH="$(dpkg --print-architecture)" && \ + case "$ARCH" in \ amd64) FF_ARCH=linux64 ;; \ arm64) FF_ARCH=linuxarm64 ;; \ - *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + *) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \ esac && \ wget -4 --tries=3 --timeout=30 -qO /tmp/ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${FF_ARCH}-gpl.tar.xz" && \ mkdir -p /tmp/ff && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ff --strip-components=1 && \ @@ -68,11 +88,12 @@ RUN case "$TARGETARCH" in \ # Bundle cloudflared so `unarr funnel on` (default: on, see config defaults) # Just Works on a headless container with no first-run network round-trip. -RUN case "$TARGETARCH" in \ - amd64) CF_ARCH=amd64 ;; \ - arm64) CF_ARCH=arm64 ;; \ - arm) CF_ARCH=armhf ;; \ - *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ +RUN ARCH="$(dpkg --print-architecture)" && \ + case "$ARCH" in \ + amd64) CF_ARCH=amd64 ;; \ + arm64) CF_ARCH=arm64 ;; \ + armhf) CF_ARCH=armhf ;; \ + *) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \ esac && \ wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ chmod +x /usr/local/bin/cloudflared diff --git a/go.mod b/go.mod index a47f6e3..f8d42d8 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,10 @@ require ( github.com/huin/goupnp v1.3.0 github.com/olekukonko/tablewriter v1.1.4 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/torrentclaw/go-client v0.2.0 golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb ) @@ -113,7 +115,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -127,7 +128,6 @@ require ( golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/internal/agent/client.go b/internal/agent/client.go index fcf21d2..fc817d5 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -139,10 +139,11 @@ func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, succes // will reach the same conclusion via HEAD probes anyway if this call // fails. We log the error in the caller but don't retry — by the time // a retry would land the user is likely already playing. -func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error { +func (c *Client) MarkSessionReady(ctx context.Context, sessionID string, health *SessionHealth) error { req := struct { - SessionID string `json:"sessionId"` - }{SessionID: sessionID} + SessionID string `json:"sessionId"` + Health *SessionHealth `json:"health,omitempty"` + }{SessionID: sessionID, Health: health} var resp StatusResponse if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil { return fmt.Errorf("mark session ready: %w", err) @@ -150,6 +151,46 @@ func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error { return nil } +// ReportSessionError is the failure-path counterpart of MarkSessionReady: it +// tells the web a streaming session can NOT start (file gone, path rejected, +// ffmpeg missing, spawn failure…). The web marks the session failed, pushes an +// SSE "failed" event so the player stops probing a playlist that will never +// exist, and self-heals stale library state on code "file_missing". +// +// code is one of the stable machine codes the web understands: +// "file_missing" | "path_rejected" | "no_video_file" | "ffmpeg_unavailable" | +// "start_failed". message is free-form detail for diagnostics. +// +// Best-effort like MarkSessionReady: on older web deployments without the +// endpoint this 404s — the caller logs and the player falls back to its +// probe-deadline behaviour, exactly as before this channel existed. +func (c *Client) ReportSessionError(ctx context.Context, sessionID, code, message string) error { + req := struct { + SessionID string `json:"sessionId"` + Code string `json:"code"` + Message string `json:"message,omitempty"` + }{SessionID: sessionID, Code: code, Message: message} + var resp StatusResponse + if err := c.doPost(ctx, "/api/internal/agent/session-error", req, &resp); err != nil { + return fmt.Errorf("report session error: %w", err) + } + return nil +} + +// SessionHealth is an OPTIONAL live-transcode health snapshot attached to a +// session-ready report (F3). A nil *SessionHealth means the agent has no +// telemetry to share (cache hit, direct-play, or progress not yet stable) and +// the web side keeps its stall-shape heuristic. Old web replicas ignore the +// extra field; old agents simply never send it. +type SessionHealth struct { + // "ok" (≥ realtime) | "marginal" (keeps up barely) | "struggling" (can't). + Health string `json:"health"` + // ffmpeg speed= EWMA: 1.0 = exactly realtime, < 1.0 = slower than playback. + RealtimeRatio float64 `json:"realtimeRatio"` + // "realtime" | "transcode" (encoder is the wall) | "input_bound" (source read). + Reason string `json:"reason"` +} + // RefreshStreamURL re-resolves a fresh debrid direct URL for a live streaming // session (hueco #2 / 2c). Called by the daemon when a debrid source expires // mid-stream (the link is time-limited; the content is still cached). Returns diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index fa1e27a..93559a8 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -56,6 +56,11 @@ type Daemon struct { OnStreamSession func(sess StreamSession) OnControlAction func(action, taskID string, deleteFiles bool) GetActiveCount func() int // returns number of active downloads (wired from manager) + // OnAgentKeyMinted fires when a register reply carries a freshly-minted + // per-machine key (the daemon registered with a general/legacy key). cmd + // persists it so the next start authenticates with the bound agent key — + // migrating legacy agents and stopping the per-restart re-mint. + OnAgentKeyMinted func(newKey string) // State User UserInfo @@ -186,6 +191,12 @@ func (d *Daemon) Register(ctx context.Context) error { return fmt.Errorf("register: %w (after %d retries)", err, maxRetries) } + // Registered with a general/legacy key → the server minted a per-machine key. + // Persist it (cmd wires the callback) so the next start uses the bound key. + if resp.AgentKey != "" && d.OnAgentKeyMinted != nil { + d.OnAgentKeyMinted(resp.AgentKey) + } + d.User = resp.User d.Features = resp.Features now := time.Now() diff --git a/internal/agent/mirror_client.go b/internal/agent/mirror_client.go index 683b92b..5364be0 100644 --- a/internal/agent/mirror_client.go +++ b/internal/agent/mirror_client.go @@ -13,7 +13,7 @@ import ( type MirrorEntry struct { URL string `json:"url"` Label string `json:"label"` - Kind string `json:"kind"` // "clearnet" | "tor" + Kind string `json:"kind"` // "clearnet" | "tor" Primary bool `json:"primary"` } diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 3ac61b9..d725542 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -66,6 +66,18 @@ type SyncClient struct { // It should delete the files and return the IDs of successfully deleted items. OnDeleteFiles func(items []LibraryDeleteRequest) []int + // OnSubtitleFetch is called when the server requests on-demand subtitle + // downloads. It should download each (from req.URL, already VTT), write a + // sidecar next to req.FilePath, and return the IDs successfully fetched plus + // the ones that failed (so the web can mark them errored). + OnSubtitleFetch func(reqs []SubtitleFetchRequest) ([]int, []SubtitleFetchError) + + // OnRevoked is called when a sync is rejected because this agent's credential + // was revoked (the user deleted the agent from the dashboard). The daemon + // wires this to wipe the stored key + stop — it must NOT keep retrying or the + // server will reject every sync forever. + OnRevoked func(err error) + // SyncNow triggers an immediate sync (e.g., on task completion). SyncNow chan struct{} @@ -83,6 +95,11 @@ type SyncClient struct { // deleteInFlight tracks item IDs currently being processed or awaiting confirmation. // Prevents the same file from being passed to OnDeleteFiles multiple times. deleteInFlight map[int]struct{} + + // Subtitle-fetch jobs awaiting confirmation + dedup (guarded by pendingDeleteMu). + pendingSubtitlesFetched []int + pendingSubtitlesFailed []SubtitleFetchError + subtitleInFlight map[int]struct{} } // NewSyncClient creates a sync client. @@ -152,6 +169,12 @@ func (sc *SyncClient) doSync(ctx context.Context) { resp, err := sc.client.Sync(ctx, req) if err != nil { if ctx.Err() == nil { + // Credential revoked (agent deleted from the dashboard) → stop; don't + // spam a sync the server will reject forever. + if IsRevoked(err) && sc.OnRevoked != nil { + sc.OnRevoked(err) + return + } log.Printf("sync failed: %v", err) } return @@ -208,6 +231,20 @@ func (sc *SyncClient) buildRequest() SyncRequest { } sc.pendingDeleteConfirmed = nil } + if len(sc.pendingSubtitlesFetched) > 0 { + req.SubtitlesFetched = sc.pendingSubtitlesFetched + for _, id := range sc.pendingSubtitlesFetched { + delete(sc.subtitleInFlight, id) + } + sc.pendingSubtitlesFetched = nil + } + if len(sc.pendingSubtitlesFailed) > 0 { + req.SubtitlesFailed = sc.pendingSubtitlesFailed + for _, f := range sc.pendingSubtitlesFailed { + delete(sc.subtitleInFlight, f.ID) + } + sc.pendingSubtitlesFailed = nil + } sc.pendingDeleteMu.Unlock() return req } @@ -279,6 +316,37 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) { }(newItems) } } + + // On-demand subtitle fetches — dedup against in-flight, run off the sync + // goroutine (network + disk I/O), confirm on the next cycle. + if len(resp.SubtitleFetches) > 0 && sc.OnSubtitleFetch != nil { + sc.pendingDeleteMu.Lock() + if sc.subtitleInFlight == nil { + sc.subtitleInFlight = make(map[int]struct{}) + } + var newReqs []SubtitleFetchRequest + for _, r := range resp.SubtitleFetches { + if _, inFlight := sc.subtitleInFlight[r.ID]; !inFlight { + newReqs = append(newReqs, r) + sc.subtitleInFlight[r.ID] = struct{}{} + } + } + sc.pendingDeleteMu.Unlock() + + if len(newReqs) > 0 { + go func(reqs []SubtitleFetchRequest) { + done, failed := sc.OnSubtitleFetch(reqs) + // Both done and failed are reported on the next uplink; buildRequest + // clears them from subtitleInFlight when it flushes them. A failure + // becomes status='error' on the web (no silent infinite retry — the + // user re-requests, which creates a fresh row). + sc.pendingDeleteMu.Lock() + sc.pendingSubtitlesFetched = append(sc.pendingSubtitlesFetched, done...) + sc.pendingSubtitlesFailed = append(sc.pendingSubtitlesFailed, failed...) + sc.pendingDeleteMu.Unlock() + }(newReqs) + } + } } // runWakeListener holds a long-poll connection to /api/internal/agent/wake. diff --git a/internal/agent/types.go b/internal/agent/types.go index 43f5a8c..8b79dff 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -1,7 +1,10 @@ package agent import ( + "errors" "fmt" + "net/http" + "strings" "time" ) @@ -65,7 +68,13 @@ type RegisterRequest struct { // RegisterResponse is returned by the server after registration. type RegisterResponse struct { - Success bool `json:"success"` + Success bool `json:"success"` + // AgentKey is a freshly-minted per-machine API key, present only when the + // CLI registered with the user's general key (manual-paste bootstrap). The + // CLI must persist it and authenticate with it from then on, discarding the + // general key. Empty in the browser-authorize path (the token already IS the + // agent key) and on every later register. + AgentKey string `json:"agentKey,omitempty"` User UserInfo `json:"user"` Features FeatureFlags `json:"features"` } @@ -198,6 +207,32 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message) } +// IsRevoked reports whether an error is an EXPLICIT server revocation signal — +// the user deleted this agent from the dashboard. The server sends 410 +// agent_revoked (the registration is tombstoned OR the per-machine key was +// revoked — the auth layer maps a revoked agent key to 410, not 401) or 403 +// agent_key_mismatch (the key belongs to another machine). On these the daemon +// wipes its credential and requires a fresh `unarr login`. +// +// A BARE 401 is deliberately NOT treated as revoked: it's ambiguous (a deploy +// blip, a load-balancer hiccup, a transient auth error) and must never wipe a +// working agent's credential. The retry/log paths handle a transient 401; a +// genuine revocation always arrives as 410. +func IsRevoked(err error) bool { + var he *HTTPError + if !errors.As(err, &he) { + return false + } + if he.StatusCode == http.StatusGone { + return true + } + if he.StatusCode == http.StatusForbidden && + strings.Contains(he.Message, "agent_key_mismatch") { + return true + } + return false +} + // AgentInfo holds metadata about the running agent for display. type AgentInfo struct { ID string @@ -331,6 +366,17 @@ type LibrarySyncRequest struct { AgentID string `json:"agentId,omitempty"` // lets the server scope stale-cleanup per agent IsLastBatch bool `json:"isLastBatch"` SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session + // ScanRoots lists EVERY root this sync session covered (a session spans all + // roots since 1.0.9 — one syncStartedAt, one isLastBatch). The server scopes + // stale-row cleanup of a partial session to these prefixes. Older servers + // ignore the field and fall back to ScanPath. + ScanRoots []string `json:"scanRoots,omitempty"` + // FullCycle marks a session that covered every root the agent scans + // (daemon auto-scan, `unarr scan` without args). The server may then reap + // unseen rows REGARDLESS of path prefix — old-base-path ghost rows + // included. Must stay false for a manual subtree scan or when any root's + // scan failed, or the cleanup would reap rows the session never visited. + FullCycle bool `json:"fullCycle,omitempty"` } // LibrarySyncItem is a single scanned media file with ffprobe metadata. @@ -398,6 +444,11 @@ type SyncRequest struct { Tasks []TaskState `json:"tasks"` CanDelete bool `json:"canDelete"` // library.allow_delete is enabled DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk + // Subtitle-fetch job IDs the agent completed (sidecar written to disk). + SubtitlesFetched []int `json:"subtitlesFetched,omitempty"` + // Subtitle-fetch jobs that permanently failed (download/write error) — the web + // marks them errored so the UI fails fast instead of waiting for a timeout. + SubtitlesFailed []SubtitleFetchError `json:"subtitlesFailed,omitempty"` // Live managed-VPN split-tunnel state, sent every sync so the web sees the // WireGuard slot owner update in near-realtime (vs. register, once at startup). // VPNActive has no omitempty: false (tunnel down) must reach the server so it @@ -450,6 +501,20 @@ type StreamSession struct { // omitted. Forces a full video re-encode (the overlay can't ride a copy // path), so the web only sends it when the user picks a bitmap sub. BurnSubtitleIndex *int `json:"burnSubtitleIndex,omitempty"` + // StartSec is the playback position (seconds) the viewer opens at — the + // saved resume point, or the current position on a quality/audio switch. + // HLS sessions spawn the FIRST ffmpeg already seeked there instead of + // encoding from segment 0 and immediately seek-restarting (double spawn, + // slow resume). 0/omitted = start at the beginning. Older daemons simply + // don't decode the field and keep the old start-at-0 behaviour. + StartSec float64 `json:"startSec,omitempty"` + // Prewarm marks a background cache-fill session (next-episode prewarm, + // hover prewarm): the daemon must encode it WITHOUT displacing the + // viewer's live session — it waits until the active encode finishes and + // registers alongside instead of evicting (Register kills every other + // session; a prewarm claimed mid-playback used to kill the stream the + // user was watching). False/omitted = a real viewer session. + Prewarm bool `json:"prewarm,omitempty"` // PlayMethod is how the daemon should serve this session: // "" — default (HLS transcode); also what legacy servers send. // "direct" — the source is already browser-native (the web decided this @@ -470,14 +535,31 @@ type StreamSession struct { // SyncResponse is returned by the server with all pending actions for the CLI. type SyncResponse struct { - NewTasks []Task `json:"newTasks,omitempty"` - Controls []ControlAction `json:"controls,omitempty"` - StreamRequests []StreamRequest `json:"streamRequests,omitempty"` - StreamSessions []StreamSession `json:"streamSessions,omitempty"` - Watching bool `json:"watching"` - Upgrade *UpgradeSignal `json:"upgrade,omitempty"` - Scan bool `json:"scan,omitempty"` - FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"` + NewTasks []Task `json:"newTasks,omitempty"` + Controls []ControlAction `json:"controls,omitempty"` + StreamRequests []StreamRequest `json:"streamRequests,omitempty"` + StreamSessions []StreamSession `json:"streamSessions,omitempty"` + Watching bool `json:"watching"` + Upgrade *UpgradeSignal `json:"upgrade,omitempty"` + Scan bool `json:"scan,omitempty"` + FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"` + SubtitleFetches []SubtitleFetchRequest `json:"subtitleFetches,omitempty"` +} + +// SubtitleFetchRequest is a server-side request to download a subtitle (from our +// proxy URL, already charset-fixed + VTT) and save it as a sidecar next to a +// media file. URL is the absolute /api/internal/subtitles/proxy URL. +type SubtitleFetchRequest struct { + ID int `json:"id"` + FilePath string `json:"filePath"` + Lang string `json:"lang"` + URL string `json:"url"` +} + +// SubtitleFetchError reports a permanently-failed subtitle fetch back to the web. +type SubtitleFetchError struct { + ID int `json:"id"` + Error string `json:"error"` } // --------------------------------------------------------------------------- diff --git a/internal/cmd/auth_browser.go b/internal/cmd/auth_browser.go index 186813a..68256df 100644 --- a/internal/cmd/auth_browser.go +++ b/internal/cmd/auth_browser.go @@ -24,7 +24,7 @@ const browserAuthTimeout = 60 * time.Second // 3. User logs in and clicks "Authorize" on the web page // 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state} // 5. CLI validates state, extracts token, closes server -func browserAuth(apiURL string) (string, error) { +func browserAuth(apiURL, agentID string) (string, error) { // Validate apiURL is a well-formed HTTP(S) URL parsed, err := url.Parse(apiURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { @@ -96,8 +96,12 @@ func browserAuth(apiURL string) (string, error) { } }() - // Open browser + // Open browser. Forward the agentId so the server mints a per-machine key + // bound to it (omitted → server falls back to the legacy general key). authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port) + if agentID != "" { + authURL += "&agentId=" + url.QueryEscape(agentID) + } openBrowser(authURL) // Listen for Enter key to skip to manual fallback diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 56f7fd2..c887435 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -653,6 +653,14 @@ func runDaemonStart() error { } } + // Wire: sync receives on-demand subtitle-fetch jobs (write VTT sidecars). + // Always available (additive, no deletion) as long as we have scan paths. + if len(daemonCfg.ScanPaths) > 0 { + sc.OnSubtitleFetch = func(reqs []agent.SubtitleFetchRequest) ([]int, []agent.SubtitleFetchError) { + return library.FetchSubtitles(reqs, daemonCfg.ScanPaths) + } + } + // Wire: sync receives stream requests for completed downloads d.OnStreamRequested = func(sr agent.StreamRequest) { if streamSrv.CurrentTaskID() == sr.TaskID { @@ -683,65 +691,19 @@ func runDaemonStart() error { }() } - allowedRoots := streamAllowedRoots(cfg) - - filePath := filepath.Clean(sr.FilePath) // Self-heal a base-path mismatch: the web may hand us a path under an old // root (e.g. /mnt/nas/peliculas/… from before a binary→docker move) that // is now outside our allowed dirs but whose file still exists under a - // current root (/downloads/…). Remap the path's tail onto an allowed root - // so playback works immediately; the next re-scan persists the fix to the - // DB. See docs/plans/unarr-path-resilience.md. - if !isAllowedStreamPath(filePath, allowedRoots...) { - if remapped := relocateUnreachable(filePath, allowedRoots); remapped != "" { - log.Printf("[%s] stream self-heal: remapped %s → %s", agent.ShortID(sr.TaskID), filePath, remapped) - filePath = remapped - } else { - log.Printf("[%s] stream request rejected: path outside allowed dirs: %s", agent.ShortID(sr.TaskID), filePath) - reportStreamError(fmt.Sprintf("path outside allowed dirs: %s", filePath)) - return - } - } - // os.Stat over NFS can transiently fail (ESTALE/EAGAIN/timeout) right - // after a remount or under load. Retry a few times before giving up so - // a hiccup doesn't surface as a spurious "file not found" — this is the - // root of the intermittent "works on the 3rd try" stream failures. - var info os.FileInfo - var statErr error - for attempt := 0; attempt < 3; attempt++ { - if info, statErr = os.Stat(filePath); statErr == nil { - break - } - if attempt < 2 { - time.Sleep(300 * time.Millisecond) - } - } - if statErr != nil { - // Last resort before failing: the file may simply have moved within - // an allowed root — try to relocate it by path tail. - if remapped := relocateUnreachable(filePath, allowedRoots); remapped != "" { - log.Printf("[%s] stream self-heal: relocated missing %s → %s", agent.ShortID(sr.TaskID), filePath, remapped) - filePath = remapped - info, statErr = os.Stat(filePath) - } - } - if statErr != nil { - log.Printf("[%s] stream request: file not found after retries: %s (%v)", agent.ShortID(sr.TaskID), filePath, statErr) - reportStreamError(fmt.Sprintf("file not found: %s", filePath)) + // current root (/downloads/…). resolvePlayableFile remaps, stat-retries + // (NFS) and resolves directories; the next re-scan persists the fix to + // the DB. See docs/plans/unarr-path-resilience.md. + filePath, errCode, perr := resolvePlayableFile(sr.FilePath, streamAllowedRoots(cfg), agent.ShortID(sr.TaskID)) + if perr != nil { + log.Printf("[%s] stream request rejected (%s): %v", agent.ShortID(sr.TaskID), errCode, perr) + reportStreamError(perr.Error()) return } - if info.IsDir() { - found := engine.FindVideoFile(filePath) - if found == "" { - log.Printf("[%s] stream request: no video file in directory: %s", agent.ShortID(sr.TaskID), filePath) - reportStreamError(fmt.Sprintf("no video file in directory: %s", filePath)) - return - } - filePath = found - log.Printf("[%s] resolved directory to video file: %s", agent.ShortID(sr.TaskID), filepath.Base(filePath)) - } - cancelStreamContexts() streamSrv.SetFile(engine.NewDiskFileProvider(filePath), sr.TaskID) log.Printf("[%s] streaming from disk: %s → %s", agent.ShortID(sr.TaskID), filepath.Base(filePath), streamSrv.URL()) @@ -771,20 +733,104 @@ func runDaemonStart() error { return // already running } + // failSession logs AND reports a startup failure to the web — every + // abort path in this handler must go through it. A silent `return` + // here left the player probing a playlist that would never exist + // until its 30s deadline (incident 2026-06-10: deleted file + stale + // library row = eternal "Preparando sesión"). Best-effort: on old web + // deployments the endpoint 404s and the player falls back to the + // probe deadline, exactly as before. + failSession := func(sessionID, code, message string) { + log.Printf("[hls %s] failed (%s): %s", agent.ShortID(sessionID), code, message) + go func() { + // Fresh context on purpose: failures cluster exactly when the + // daemon ctx is being cancelled (shutdown kills in-flight + // session starts), and a report derived from it would die + // before reaching the web. The 10s cap still bounds it. + rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := agentClient.ReportSessionError(rctx, sessionID, code, message); err != nil { + log.Printf("[hls %s] session error report failed: %v", agent.ShortID(sessionID), err) + } + }() + } + + // markReady reports "first bytes are servable" for the no-transcode + // paths (direct-play, remux, debrid direct) — one place instead of a + // copy per branch. HLS sessions report via watchSessionReady instead + // (they wait for seg-0 + attach a health snapshot). + markReady := func(sessionID string) { + go func() { + rctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := agentClient.MarkSessionReady(rctx, sessionID, nil); err != nil { + log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sessionID), err) + } + }() + } + // startHLSPlayback starts an HLS encode (local file or debrid URL) and // wires it into the StreamServer. Shared by the local-file HLS path and // the debrid HLS-from-URL path (hueco #2 / 2b) so both register, probe // off the sync loop, and report readiness identically. + // + // Prewarm sessions (background cache-fill: next-episode, hover) take a + // deferential path: wait until no live encode is running (never steal + // the encoder from the viewer), then register WITHOUT displacing other + // sessions. Before this, a prewarm claimed mid-playback went through + // Register() and KILLED the stream the user was watching (verified + // 2026-06-10: prewarm started → live session "closed (cache + // discarded)" → player 404). startHLSPlayback := func(hlsCfg engine.HLSSessionConfig, hlsCtx context.Context, hlsCancel context.CancelFunc) { playerSessionRegistry.add(hlsCfg.SessionID, hlsCancel) + prewarm := sess.Prewarm go func() { + if prewarm { + // Defer until the encoder is free. Poll is cheap (10 s); + // cap the wait at 30 min — a prewarm that can't start + // within an episode's runtime has lost its purpose. + deadline := time.Now().Add(30 * time.Minute) + for streamSrv.HLS().HasLiveEncode() { + if time.Now().After(deadline) || hlsCtx.Err() != nil { + playerSessionRegistry.remove(hlsCfg.SessionID) + hlsCancel() + log.Printf("[hls %s] prewarm abandoned (encoder busy %s)", + agent.ShortID(hlsCfg.SessionID), "30m") + return + } + select { + case <-hlsCtx.Done(): + playerSessionRegistry.remove(hlsCfg.SessionID) + return + case <-time.After(10 * time.Second): + } + } + } else { + // REAL session: reap in-flight prewarm encodes BEFORE + // StartHLSSession so the per-key cache writer-lock is + // free and the viewer's encode lands in the persistent + // cache (not an uncached tmpdir). A SEALED prewarm is + // unaffected — this session simply cache-HITs it. + if n := streamSrv.HLS().CloseWhere(func(s *engine.HLSSession) bool { return s.IsPrewarm() }); n > 0 { + log.Printf("[hls %s] reaped %d in-flight prewarm(s) for the viewer session", + agent.ShortID(hlsCfg.SessionID), n) + } + } hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg) if err != nil { playerSessionRegistry.remove(hlsCfg.SessionID) hlsCancel() - log.Printf("[hls %s] start failed: %v", agent.ShortID(hlsCfg.SessionID), err) + failSession(hlsCfg.SessionID, sessErrStartFailed, err.Error()) return } + if prewarm { + // Side-by-side: never evict the viewer's session. A later + // REAL session still evicts this one via Register — by + // then the encode is usually sealed in the segment cache. + streamSrv.HLS().RegisterKeep(hsess) + log.Printf("[hls %s] prewarm encoding: %s", agent.ShortID(hlsCfg.SessionID), hlsCfg.FileName) + return // no viewer waiting → no ready-watcher + } streamSrv.HLS().Register(hsess) go watchSessionReady(hlsCtx, agentClient, hsess, hlsCfg.SessionID) }() @@ -814,17 +860,13 @@ func runDaemonStart() error { provider, perr := engine.NewDebridFileProvider(bctx, sess.DirectURL, sess.FileName, sess.FileSize, refresh) if perr != nil { playerSessionRegistry.remove(sess.SessionID) - log.Printf("[stream %s] debrid provider failed: %v", agent.ShortID(sess.SessionID), perr) + failSession(sess.SessionID, sessErrStartFailed, fmt.Sprintf("debrid provider: %v", perr)) return } streamSrv.SetFile(provider, sess.TaskID) log.Printf("[stream %s] debrid direct-play: %s (%d bytes)", agent.ShortID(sess.SessionID), provider.FileName(), provider.FileSize()) - rctx, rcancel := context.WithTimeout(ctx, 10*time.Second) - defer rcancel() - if err := agentClient.MarkSessionReady(rctx, sess.SessionID); err != nil { - log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sess.SessionID), err) - } + markReady(sess.SessionID) }() return } @@ -837,7 +879,7 @@ func runDaemonStart() error { if sess.DirectURL != "" { // playMethod == "hls" implied (2a returned above) tcRuntime := buildTranscodeRuntime(ctx, cfg) if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" { - log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable (debrid HLS)", agent.ShortID(sess.SessionID)) + failSession(sess.SessionID, sessErrFfmpegMissing, "ffmpeg/ffprobe unavailable (debrid HLS)") return } hlsCtx, hlsCancel := context.WithCancel(ctx) @@ -849,6 +891,8 @@ func runDaemonStart() error { Quality: sess.Quality, AudioIndex: sess.AudioIndex, BurnSubtitleIndex: sess.BurnSubtitleIndex, + StartSec: sess.StartSec, + Prewarm: sess.Prewarm, Transcode: tcRuntime, Cache: hlsCache, // 2c: refresh the debrid link if it expires mid-transcode; the @@ -861,43 +905,22 @@ func runDaemonStart() error { return } - filePath := sess.FilePath - if filePath == "" { - log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID)) + if sess.FilePath == "" { + failSession(sess.SessionID, sessErrStartFailed, "empty file path") return } - filePath = filepath.Clean(filePath) - // Apply the SAME base-path self-heal remap as the raw /stream handler - // (OnStreamRequest above). Without it, a path under an old/host base + // SAME base-path self-heal + stat-retry + dir resolution as the raw + // /stream handler (resolvePlayableFile). A path under an old/host base // (e.g. /mnt/nas/peliculas/… handed by the web while this docker agent - // mounts that media at /downloads) is rejected here even though the raw - // path self-heals it — so the web silently falls back to the raw stream - // and HLS/remux never runs (no transcode, slow funnel start). NOTE: this - // replicates only the lexical-remap; the raw handler additionally retries - // os.Stat for transient NFS errors. The HLS dir-check below proceeds (not - // rejects) on a stat error, so it tolerates an NFS blip differently. + // mounts that media at /downloads) remaps onto the current root; a path + // whose file is genuinely gone fails fast as "file_missing" so the web + // can prune the stale library row and the player can fall back, instead + // of the player probing a playlist that will never exist. // See docs/plans/unarr-path-resilience.md. - hlsAllowedRoots := streamAllowedRoots(cfg) - if !isAllowedStreamPath(filePath, hlsAllowedRoots...) { - if remapped := relocateUnreachable(filePath, hlsAllowedRoots); remapped != "" { - log.Printf("[hls %s] self-heal: remapped %s → %s", - agent.ShortID(sess.SessionID), filePath, remapped) - filePath = remapped - } else { - log.Printf("[hls %s] rejected: path outside allowed dirs: %s", - agent.ShortID(sess.SessionID), filePath) - return - } - } - // Resolve directory → first video file (matches StreamRequest behavior). - if info, err := os.Stat(filePath); err == nil && info.IsDir() { - found := engine.FindVideoFile(filePath) - if found == "" { - log.Printf("[hls %s] rejected: no video file in dir %s", - agent.ShortID(sess.SessionID), filePath) - return - } - filePath = found + filePath, errCode, perr := resolvePlayableFile(sess.FilePath, streamAllowedRoots(cfg), "hls "+agent.ShortID(sess.SessionID)) + if perr != nil { + failSession(sess.SessionID, errCode, perr.Error()) + return } // Direct-play (hueco #3 / 3a): the web decided this source is already @@ -914,19 +937,13 @@ func runDaemonStart() error { log.Printf("[stream %s] direct-play: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath)) // File is on disk → ready immediately. Tell the web so the player // attaches