2026-03-28 11:29:42 +01:00
package agent
import (
"context"
2026-04-06 17:26:32 +02:00
"errors"
2026-03-28 11:29:42 +01:00
"fmt"
"log"
2026-03-28 18:55:29 +01:00
"os"
2026-05-23 15:36:37 +02:00
"os/exec"
2026-03-28 11:29:42 +01:00
"runtime"
2026-04-06 17:26:32 +02:00
"strings"
2026-03-31 16:55:50 +02:00
"sync/atomic"
2026-03-28 11:29:42 +01:00
"time"
)
// DaemonConfig holds daemon runtime settings.
type DaemonConfig struct {
2026-05-08 15:57:02 +02:00
AgentID string
AgentName string
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
CanDelete bool // library.allow_delete is enabled
ScanPaths [ ] string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
2026-03-28 11:29:42 +01:00
}
2026-04-08 18:50:59 +02:00
// Daemon manages agent registration and the sync loop.
2026-03-28 11:29:42 +01:00
type Daemon struct {
2026-04-08 18:50:59 +02:00
cfg DaemonConfig
client * Client
sync * SyncClient
state * LocalState
2026-03-28 11:29:42 +01:00
2026-04-08 18:50:59 +02:00
// Callbacks — set by cmd/daemon.go before calling Run.
2026-03-30 23:24:16 +02:00
OnTasksClaimed func ( tasks [ ] Task )
OnStreamRequested func ( req StreamRequest )
2026-05-06 23:12:38 +02:00
OnWebRTCSession func ( sess WebRTCSession )
2026-04-08 18:50:59 +02:00
OnControlAction func ( action , taskID string , deleteFiles bool )
GetActiveCount func ( ) int // returns number of active downloads (wired from manager)
2026-03-28 11:29:42 +01:00
// State
2026-03-31 00:17:19 +02:00
User UserInfo
Features FeatureFlags
Info AgentInfo
State DaemonState
lastNotifiedVersion string
2026-03-28 18:55:29 +01:00
2026-05-22 08:33:02 +02:00
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
vpnActive bool
vpnMode string
vpnServer string
2026-03-30 23:24:16 +02:00
// Watching tracks whether a user is viewing download progress in the web UI.
2026-03-31 16:55:50 +02:00
Watching atomic . Bool
2026-03-30 23:24:16 +02:00
2026-04-08 18:50:59 +02:00
// ScanNow triggers an immediate library scan.
2026-04-07 11:36:42 +02:00
ScanNow chan struct { }
2026-03-28 11:29:42 +01:00
}
2026-04-08 18:50:59 +02:00
// NewDaemon creates a daemon with an HTTP client for sync-based communication.
func NewDaemon ( cfg DaemonConfig , client * Client ) * Daemon {
state := NewLocalState ( )
2026-03-28 11:29:42 +01:00
return & Daemon {
2026-04-08 18:50:59 +02:00
cfg : cfg ,
client : client ,
state : state ,
sync : NewSyncClient ( client , cfg , state ) ,
ScanNow : make ( chan struct { } , 1 ) ,
2026-03-28 11:29:42 +01:00
}
}
2026-04-08 18:50:59 +02:00
// SyncClient returns the sync client for external wiring.
func ( d * Daemon ) SyncClient ( ) * SyncClient { return d . sync }
2026-05-22 08:33:02 +02:00
// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the
// daemon state file (read by `unarr vpn status`). Call before Run.
func ( d * Daemon ) SetVPNState ( active bool , mode , server string ) {
d . vpnActive = active
d . vpnMode = mode
d . vpnServer = server
}
2026-04-08 18:50:59 +02:00
// UpdateStreamPort updates the stream port reported in sync requests.
func ( d * Daemon ) UpdateStreamPort ( port int ) {
d . cfg . StreamPort = port
d . sync . cfg . StreamPort = port
}
2026-03-28 18:55:29 +01:00
2026-03-28 11:29:42 +01:00
// Register registers the agent and fetches user info + features.
2026-04-06 17:26:32 +02:00
// Retries with exponential backoff on transient errors (429, 5xx, network).
2026-03-28 11:29:42 +01:00
func ( d * Daemon ) Register ( ctx context . Context ) error {
req := RegisterRequest {
2026-05-08 15:57:02 +02:00
AgentID : d . cfg . AgentID ,
Name : d . cfg . AgentName ,
OS : runtime . GOOS ,
Arch : runtime . GOARCH ,
Version : d . cfg . Version ,
DownloadDir : d . cfg . DownloadDir ,
StreamPort : d . cfg . StreamPort ,
LanIP : d . cfg . LanIP ,
TailscaleIP : d . cfg . TailscaleIP ,
HWAccel : d . cfg . HWAccel ,
MaxTranscodeHeight : d . cfg . MaxTranscodeHeight ,
2026-05-22 08:33:02 +02:00
VPNActive : d . vpnActive ,
VPNMode : d . vpnMode ,
VPNServer : d . vpnServer ,
2026-03-28 11:29:42 +01:00
}
if free , total , err := DiskInfo ( d . cfg . DownloadDir ) ; err == nil {
req . DiskFreeBytes = free
req . DiskTotalBytes = total
}
2026-04-06 17:26:32 +02:00
const maxRetries = 5
backoff := 5 * time . Second
var resp * RegisterResponse
var err error
for attempt := range maxRetries {
2026-04-08 18:50:59 +02:00
resp , err = d . client . Register ( ctx , req )
2026-04-06 17:26:32 +02:00
if err == nil {
break
}
if ! isTransientError ( err ) {
return fmt . Errorf ( "register: %w" , err )
}
log . Printf ( "Register failed (attempt %d/%d): %v - retrying in %v" , attempt + 1 , maxRetries , err , backoff )
timer := time . NewTimer ( backoff )
select {
case <- ctx . Done ( ) :
timer . Stop ( )
return fmt . Errorf ( "register: %w" , ctx . Err ( ) )
case <- timer . C :
}
backoff = min ( backoff * 2 , 60 * time . Second )
}
2026-03-28 11:29:42 +01:00
if err != nil {
2026-04-06 17:26:32 +02:00
return fmt . Errorf ( "register: %w (after %d retries)" , err , maxRetries )
2026-03-28 11:29:42 +01:00
}
d . User = resp . User
d . Features = resp . Features
2026-03-28 18:55:29 +01:00
now := time . Now ( )
2026-03-28 11:29:42 +01:00
d . Info = AgentInfo {
ID : d . cfg . AgentID ,
Name : d . cfg . AgentName ,
User : resp . User ,
Features : resp . Features ,
2026-03-28 18:55:29 +01:00
StartedAt : now ,
}
d . State = DaemonState {
AgentID : d . cfg . AgentID ,
Status : "running" ,
Version : d . cfg . Version ,
PID : os . Getpid ( ) ,
StartedAt : now ,
MethodStats : make ( map [ string ] int ) ,
2026-05-22 08:33:02 +02:00
VPNActive : d . vpnActive ,
VPNMode : d . vpnMode ,
VPNServer : d . vpnServer ,
2026-03-28 11:29:42 +01:00
}
2026-03-28 18:55:29 +01:00
WriteState ( & d . State )
2026-03-28 11:29:42 +01:00
return nil
}
2026-04-08 18:50:59 +02:00
// Run registers the agent and starts the sync loop.
// Blocks until ctx is cancelled.
2026-03-28 11:29:42 +01:00
func ( d * Daemon ) Run ( ctx context . Context ) error {
// Register
if err := d . Register ( ctx ) ; err != nil {
return err
}
log . Printf ( "Agent registered: %s (%s) [%s]" , d . User . Name , d . User . Email , d . User . Plan )
log . Printf ( "Features: torrent=%v debrid=%v usenet=%v" , d . Features . Torrent , d . Features . Debrid , d . Features . Usenet )
2026-03-28 21:36:12 +01:00
2026-05-23 15:36:37 +02:00
// Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
// Without par2, a single bad segment corrupts the file silently; without
// an extractor, RAR-packed downloads can't be unpacked. Warn loudly at
// startup so the operator installs them before the first download fails.
if d . Features . Usenet {
if _ , err := exec . LookPath ( "par2" ) ; err != nil {
log . Printf ( "[usenet] WARNING: par2 not found in PATH — corrupted segments cannot be repaired and extraction may fail. Install par2 (apt install par2 / brew install par2)." )
}
_ , unrarErr := exec . LookPath ( "unrar" )
_ , sevenZErr := exec . LookPath ( "7z" )
if unrarErr != nil && sevenZErr != nil {
log . Printf ( "[usenet] WARNING: no archive extractor (unrar or 7z) found — RAR-packed downloads cannot be unpacked. Install unrar or 7z." )
}
}
2026-04-08 18:50:59 +02:00
// Wire sync callbacks
d . sync . OnNewTasks = func ( tasks [ ] Task ) {
if d . OnTasksClaimed != nil {
d . OnTasksClaimed ( tasks )
2026-03-28 11:29:42 +01:00
}
}
2026-04-08 18:50:59 +02:00
d . sync . OnControl = func ( action , taskID string , deleteFiles bool ) {
if d . OnControlAction != nil {
d . OnControlAction ( action , taskID , deleteFiles )
2026-03-28 18:55:29 +01:00
}
}
2026-04-08 18:50:59 +02:00
d . sync . OnStreamRequest = func ( req StreamRequest ) {
if d . OnStreamRequested != nil {
d . OnStreamRequested ( req )
}
2026-03-28 18:55:29 +01:00
}
2026-05-06 23:12:38 +02:00
d . sync . OnWebRTCSession = func ( sess WebRTCSession ) {
if d . OnWebRTCSession != nil {
d . OnWebRTCSession ( sess )
}
}
2026-04-08 18:50:59 +02:00
d . sync . OnUpgrade = func ( version string ) {
if version != d . lastNotifiedVersion {
d . lastNotifiedVersion = version
log . Printf ( "New version available: %s (run `unarr self-update` to upgrade)" , version )
}
2026-03-28 18:55:29 +01:00
}
2026-04-08 18:50:59 +02:00
d . sync . OnScan = func ( ) {
2026-04-07 11:36:42 +02:00
log . Printf ( "Library scan requested by server" )
select {
case d . ScanNow <- struct { } { } :
2026-04-08 18:50:59 +02:00
default :
2026-04-07 11:36:42 +02:00
}
}
2026-04-08 18:50:59 +02:00
d . sync . OnWatchingChange = func ( watching bool ) {
d . Watching . Store ( watching )
2026-03-28 18:55:29 +01:00
}
2026-05-22 08:33:02 +02:00
d . sync . GetVPNState = func ( ) ( bool , string , string ) {
return d . vpnActive , d . vpnMode , d . vpnServer
}
2026-04-08 18:50:59 +02:00
d . sync . OnSyncSuccess = func ( ) {
d . State . LastHeartbeat = time . Now ( )
if d . GetActiveCount != nil {
d . State . ActiveTasks = d . GetActiveCount ( )
2026-03-28 18:55:29 +01:00
}
2026-04-08 18:50:59 +02:00
WriteState ( & d . State )
2026-03-28 11:29:42 +01:00
}
2026-04-08 18:50:59 +02:00
// Start sync loop (blocks)
return d . sync . Run ( ctx )
2026-04-07 19:08:37 +02:00
}
2026-04-08 18:50:59 +02:00
// TriggerSync requests an immediate sync cycle.
func ( d * Daemon ) TriggerSync ( ) {
d . sync . TriggerSync ( )
2026-03-28 21:36:12 +01:00
}
2026-04-08 18:50:59 +02:00
// Deregister notifies the server of graceful shutdown.
func ( d * Daemon ) Deregister ( ) {
2026-03-28 18:55:29 +01:00
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
2026-04-08 18:50:59 +02:00
if err := d . client . Deregister ( ctx , d . cfg . AgentID ) ; err != nil {
2026-03-28 18:55:29 +01:00
log . Printf ( "Deregister failed: %v" , err )
} else {
log . Println ( "Agent deregistered" )
}
RemoveState ( )
}
2026-04-06 17:26:32 +02:00
// isTransientError returns true for errors worth retrying (429, 5xx, network).
func isTransientError ( err error ) bool {
if err == nil {
return false
}
var httpErr * HTTPError
if errors . As ( err , & httpErr ) {
return httpErr . StatusCode == 429 || httpErr . StatusCode >= 500
}
lower := strings . ToLower ( err . Error ( ) )
for _ , keyword := range [ ] string { "connection refused" , "no such host" , "timeout" , "request failed" } {
if strings . Contains ( lower , keyword ) {
return true
}
}
return false
}