2026-03-28 11:29:42 +01:00
|
|
|
package cmd
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log"
|
|
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
"path/filepath"
|
2026-04-08 23:36:18 +02:00
|
|
|
"strings"
|
2026-03-28 11:29:42 +01:00
|
|
|
"syscall"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/fatih/color"
|
|
|
|
|
"github.com/spf13/cobra"
|
2026-03-30 13:06:07 +02:00
|
|
|
"github.com/torrentclaw/unarr/internal/agent"
|
|
|
|
|
"github.com/torrentclaw/unarr/internal/config"
|
|
|
|
|
"github.com/torrentclaw/unarr/internal/engine"
|
|
|
|
|
"github.com/torrentclaw/unarr/internal/library"
|
2026-05-08 15:57:02 +02:00
|
|
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
2026-03-30 13:06:07 +02:00
|
|
|
"github.com/torrentclaw/unarr/internal/usenet/download"
|
2026-03-28 11:29:42 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// newStartCmd creates the top-level `unarr start` command.
|
|
|
|
|
func newStartCmd() *cobra.Command {
|
|
|
|
|
return &cobra.Command{
|
|
|
|
|
Use: "start",
|
|
|
|
|
Short: "Start the download daemon (foreground)",
|
|
|
|
|
Long: `Start the unarr daemon in the foreground.
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
Registers with the server, receives download tasks via periodic sync,
|
|
|
|
|
and executes them using the configured download method.
|
2026-03-28 21:12:12 +01:00
|
|
|
Supports torrent, debrid, and usenet downloads concurrently.
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
The daemon syncs state with the server every 3s when someone is viewing
|
|
|
|
|
the web dashboard, or every 60s when idle. Press Ctrl+C to stop
|
|
|
|
|
gracefully — active downloads get up to 30 seconds to finish.
|
2026-03-28 21:12:12 +01:00
|
|
|
|
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
|
|
|
Requires: API key, agent ID, and download directory (run 'unarr init' first).
|
2026-03-28 21:12:12 +01:00
|
|
|
|
|
|
|
|
To run as a background service, use 'unarr daemon install' instead.`,
|
2026-03-28 11:29:42 +01:00
|
|
|
Example: ` unarr start
|
|
|
|
|
unarr start --config /path/to/config.toml`,
|
|
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
|
|
return runDaemonStart()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 19:18:13 +02:00
|
|
|
// newStopCmd creates the top-level `unarr stop` command.
|
2026-03-28 11:29:42 +01:00
|
|
|
func newStopCmd() *cobra.Command {
|
|
|
|
|
return &cobra.Command{
|
|
|
|
|
Use: "stop",
|
|
|
|
|
Short: "Stop the running daemon",
|
2026-04-10 19:18:13 +02:00
|
|
|
Long: `Stop the unarr daemon gracefully.
|
2026-03-28 21:12:12 +01:00
|
|
|
|
2026-04-10 19:18:13 +02:00
|
|
|
Reads the daemon PID from the state file and sends a graceful stop signal.
|
|
|
|
|
Works regardless of whether the daemon was started in the foreground or as a service.
|
2026-03-28 21:12:12 +01:00
|
|
|
|
2026-04-10 19:18:13 +02:00
|
|
|
To stop a service-managed daemon and prevent auto-restart, use 'unarr daemon stop' instead.`,
|
2026-03-28 21:12:12 +01:00
|
|
|
Example: ` unarr stop`,
|
2026-03-28 11:29:42 +01:00
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
2026-04-10 19:18:13 +02:00
|
|
|
return stopDaemonByPID()
|
2026-03-28 11:29:42 +01:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newDaemonCmd creates `unarr daemon` for administrative subcommands.
|
|
|
|
|
func newDaemonCmd() *cobra.Command {
|
|
|
|
|
cmd := &cobra.Command{
|
2026-03-28 21:12:12 +01:00
|
|
|
Use: "daemon <command>",
|
|
|
|
|
Short: "Manage the daemon as a system service",
|
2026-04-10 19:18:13 +02:00
|
|
|
Long: `Install, control and inspect the unarr daemon as a system service.
|
2026-03-28 21:12:12 +01:00
|
|
|
|
2026-04-10 19:18:13 +02:00
|
|
|
Linux: systemd user service (~/.config/systemd/user/unarr.service)
|
|
|
|
|
macOS: launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)
|
|
|
|
|
Windows: Task Scheduler task (runs at logon)`,
|
2026-03-28 21:12:12 +01:00
|
|
|
Example: ` unarr daemon install
|
2026-04-10 19:18:13 +02:00
|
|
|
unarr daemon start
|
|
|
|
|
unarr daemon status
|
|
|
|
|
unarr daemon logs -f
|
|
|
|
|
unarr daemon reload
|
|
|
|
|
unarr daemon restart
|
|
|
|
|
unarr daemon stop
|
2026-03-28 21:12:12 +01:00
|
|
|
unarr daemon uninstall`,
|
2026-03-28 11:29:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd.AddCommand(
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
newDaemonInstallCmdReal(),
|
|
|
|
|
newDaemonUninstallCmdReal(),
|
2026-04-10 19:18:13 +02:00
|
|
|
newDaemonStartCmd(),
|
|
|
|
|
newDaemonStopCmd(),
|
|
|
|
|
newDaemonRestartCmd(),
|
|
|
|
|
newDaemonSvcStatusCmd(),
|
|
|
|
|
newDaemonLogsCmd(),
|
|
|
|
|
newDaemonReloadCmd(),
|
2026-03-28 11:29:42 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return cmd
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func runDaemonStart() error {
|
|
|
|
|
cfg := loadConfig()
|
|
|
|
|
bold := color.New(color.Bold)
|
|
|
|
|
|
|
|
|
|
// Validate config
|
|
|
|
|
if cfg.Auth.APIKey == "" {
|
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
|
|
|
return fmt.Errorf("no API key configured — run 'unarr init' first")
|
2026-03-28 11:29:42 +01:00
|
|
|
}
|
|
|
|
|
if cfg.Agent.ID == "" {
|
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
|
|
|
return fmt.Errorf("no agent ID — run 'unarr init' first")
|
2026-03-28 11:29:42 +01:00
|
|
|
}
|
|
|
|
|
if cfg.Download.Dir == "" {
|
feat: replace setup with init wizard + interactive config menu
- `unarr init` (alias: `unarr setup`): streamlined 3-step wizard
(API key, download dir, daemon install). Removed method/name prompts
— auto-configured from defaults.
- `unarr config [category]`: interactive menu with 7 categories
(downloads, organization, notifications, device, region, connection,
advanced). Direct access via `unarr config downloads`, etc.
- Extract shared helpers (openBrowser, expandHome, isTerminal) to
helpers.go. Delete old setup.go and config.go.
- Update all "unarr setup" references to "unarr init" across daemon,
doctor, status, README, install scripts.
2026-03-29 12:09:03 +02:00
|
|
|
return fmt.Errorf("no download directory — run 'unarr init' first")
|
2026-03-28 11:29:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate configured paths are safe
|
|
|
|
|
if err := cfg.ValidatePaths(); err != nil {
|
|
|
|
|
return fmt.Errorf("unsafe configuration: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure download dir exists
|
|
|
|
|
if err := os.MkdirAll(cfg.Download.Dir, 0o755); err != nil {
|
|
|
|
|
return fmt.Errorf("create download dir: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 21:36:12 +01:00
|
|
|
// Clean up stale resume files (>7 days old)
|
|
|
|
|
resumeDir := filepath.Join(config.DataDir(), "resume")
|
|
|
|
|
if removed := download.CleanStaleFiles(resumeDir, 7*24*time.Hour); removed > 0 {
|
|
|
|
|
log.Printf("Cleaned %d stale resume file(s)", removed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
fmt.Println()
|
|
|
|
|
bold.Println(" unarr Daemon")
|
|
|
|
|
fmt.Println()
|
|
|
|
|
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
userAgent := "unarr/" + Version
|
2026-03-28 11:29:42 +01:00
|
|
|
|
2026-05-08 15:57:02 +02:00
|
|
|
// Probe HW accel + derive a sensible transcode resolution cap. The cap
|
|
|
|
|
// is what the web side uses to decide whether the user should pre-empt
|
|
|
|
|
// transcoding by downloading a smaller version (4K source on a software
|
|
|
|
|
// libx264-only host is the canonical case where pre-download wins).
|
|
|
|
|
hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
|
|
|
|
|
maxTranscodeHeight := 1080
|
|
|
|
|
if hwAccelPick != engine.HWAccelNone {
|
|
|
|
|
maxTranscodeHeight = 2160
|
|
|
|
|
}
|
|
|
|
|
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
// Create daemon config
|
2026-03-28 11:29:42 +01:00
|
|
|
daemonCfg := agent.DaemonConfig{
|
2026-05-08 15:57:02 +02:00
|
|
|
AgentID: cfg.Agent.ID,
|
|
|
|
|
AgentName: cfg.Agent.Name,
|
|
|
|
|
Version: Version,
|
|
|
|
|
DownloadDir: cfg.Download.Dir,
|
|
|
|
|
StreamPort: cfg.Download.StreamPort,
|
|
|
|
|
LanIP: engine.LanIP(),
|
|
|
|
|
TailscaleIP: engine.TailscaleIP(),
|
|
|
|
|
CanDelete: cfg.Library.AllowDelete,
|
|
|
|
|
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
|
|
|
|
|
HWAccel: string(hwAccelPick),
|
|
|
|
|
MaxTranscodeHeight: maxTranscodeHeight,
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 16:26:43 +02:00
|
|
|
// Create HTTP client with mirror failover so a `.com` block-out rolls
|
|
|
|
|
// over to `.to` / .onion without restarting the daemon.
|
|
|
|
|
agentClient := newAgentClientFromConfig(cfg, userAgent)
|
|
|
|
|
log.Printf("Transport: HTTP sync → %s (mirrors: %d)", cfg.Auth.APIURL, len(cfg.Auth.Mirrors))
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Create daemon
|
|
|
|
|
d := agent.NewDaemon(daemonCfg, agentClient)
|
2026-03-28 18:55:29 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Start SIGUSR1 reload watcher (unix only, no-op on Windows)
|
|
|
|
|
startReloadWatcher(&ReloadableConfig{Daemon: d})
|
2026-04-01 12:16:45 +02:00
|
|
|
|
|
|
|
|
// Daemon-scoped context — cancelled on shutdown
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
// Parse speed limits
|
|
|
|
|
maxDl, _ := config.ParseSpeed(cfg.Download.MaxDownloadSpeed)
|
|
|
|
|
maxUl, _ := config.ParseSpeed(cfg.Download.MaxUploadSpeed)
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Parse torrent timeouts
|
2026-03-29 19:09:51 +02:00
|
|
|
metaTimeout, _ := time.ParseDuration(cfg.Download.MetadataTimeout)
|
|
|
|
|
stallTimeout, _ := time.ParseDuration(cfg.Download.StallTimeout)
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Create progress reporter — only used for stream tasks (handleStreamTask)
|
|
|
|
|
// The sync goroutine handles all regular progress reporting.
|
|
|
|
|
statusInterval, _ := time.ParseDuration(cfg.Daemon.StatusInterval)
|
|
|
|
|
if statusInterval == 0 {
|
|
|
|
|
statusInterval = 3 * time.Second
|
|
|
|
|
}
|
|
|
|
|
reporter := engine.NewProgressReporter(agentClient, statusInterval)
|
|
|
|
|
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
// Create torrent downloader
|
|
|
|
|
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
|
|
|
|
|
DataDir: cfg.Download.Dir,
|
2026-04-08 18:50:59 +02:00
|
|
|
MetadataTimeout: metaTimeout,
|
|
|
|
|
StallTimeout: stallTimeout,
|
|
|
|
|
MaxTimeout: 0,
|
2026-03-28 11:29:42 +01:00
|
|
|
MaxDownloadRate: maxDl,
|
|
|
|
|
MaxUploadRate: maxUl,
|
2026-04-08 18:50:59 +02:00
|
|
|
ListenPort: cfg.Download.ListenPort,
|
2026-03-28 11:29:42 +01:00
|
|
|
SeedEnabled: false,
|
feat(torrent): act as WebTorrent peer for browser ↔ unarr P2P streaming
Wires anacrolix/torrent's built-in webtorrent package so a browser
running webtorrent.js can fetch pieces from this CLI via WebRTC data
channels. The daemon stays the seeder; we never relay bytes through
TorrentClaw infrastructure — same legal posture as today.
Changes:
- internal/config: new [downloads.webrtc] section
(enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass).
Disabled by default, opt-in via config.toml. When enabled but
trackers / STUN slices are empty, defaults are reapplied on Load() so
users get a working setup with a single `enabled = true`.
- internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers
/ ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList
and forces NoUpload=false when WebRTC is on (browsers can't pull
otherwise). buildMagnet now accepts variadic extra trackers and the
downloader method prepends WSS trackers so anacrolix's
webtorrent.TrackerClient picks them up first.
- internal/engine/webrtc.go: BuildICEServers helper converts the TOML
WebRTCConfig into []webrtc.ICEServer with shared TURN credentials.
- internal/cmd/daemon.go + download.go: pass WebRTC config through to
the engine.
Tests (8 new, all green; full suite 0 lint issues, 0 vet):
- buildMagnet free function: defaults-only, with extras, trim+empty-skip
- downloader method: WebRTC disabled keeps WSS out, enabled prepends them
- BuildICEServers: nil when disabled, STUN-only path, TURN+credentials
- NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC
peer enabled, magnet contains wss://tracker.torrentclaw.com)
End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a
manual test once tracker.torrentclaw.com WSS is live.
2026-05-06 08:59:58 +02:00
|
|
|
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
|
|
|
|
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
|
|
|
|
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
2026-03-28 11:29:42 +01:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("create torrent downloader: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if maxDl > 0 || maxUl > 0 {
|
|
|
|
|
dlStr, ulStr := "unlimited", "unlimited"
|
|
|
|
|
if maxDl > 0 {
|
|
|
|
|
dlStr = formatSpeedLog(maxDl)
|
|
|
|
|
}
|
|
|
|
|
if maxUl > 0 {
|
|
|
|
|
ulStr = formatSpeedLog(maxUl)
|
|
|
|
|
}
|
|
|
|
|
log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Create debrid downloader
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
debridDl := engine.NewDebridDownloader()
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
// Create download manager
|
|
|
|
|
manager := engine.NewManager(engine.ManagerConfig{
|
|
|
|
|
MaxConcurrent: cfg.Download.MaxConcurrent,
|
|
|
|
|
OutputDir: cfg.Download.Dir,
|
|
|
|
|
Notifications: cfg.Notifications.Enabled,
|
|
|
|
|
Organize: engine.OrganizeConfig{
|
|
|
|
|
Enabled: cfg.Organize.Enabled,
|
|
|
|
|
MoviesDir: cfg.Organize.MoviesDir,
|
|
|
|
|
TVShowsDir: cfg.Organize.TVShowsDir,
|
2026-04-05 23:36:01 +02:00
|
|
|
OutputDir: cfg.Download.Dir,
|
2026-03-28 11:29:42 +01:00
|
|
|
},
|
2026-04-08 18:50:59 +02:00
|
|
|
}, reporter, torrentDl, debridDl, engine.NewUsenetDownloader(agentClient))
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Create persistent stream server
|
2026-04-07 19:08:37 +02:00
|
|
|
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
|
2026-05-08 08:51:19 +02:00
|
|
|
// Reap HLS tmpdirs left over from a previous daemon run before we start
|
|
|
|
|
// accepting new sessions. The in-memory registry doesn't survive a
|
|
|
|
|
// restart, so without this disk usage grows unbounded across restarts.
|
|
|
|
|
if err := engine.CleanupHLSOrphanDirs(); err != nil {
|
|
|
|
|
log.Printf("[hls] orphan tmpdir cleanup: %v", err)
|
|
|
|
|
}
|
2026-04-07 19:08:37 +02:00
|
|
|
if err := streamSrv.Listen(ctx); err != nil {
|
|
|
|
|
return fmt.Errorf("start stream server: %w", err)
|
|
|
|
|
}
|
|
|
|
|
d.UpdateStreamPort(streamSrv.Port())
|
|
|
|
|
|
2026-05-08 15:57:02 +02:00
|
|
|
// Warn at startup if transcode is enabled but ffmpeg/ffprobe are missing.
|
|
|
|
|
// HLS sessions get rejected at runtime (see daemon.go ~line 455), but
|
|
|
|
|
// surfacing it here gives the operator a chance to install ffmpeg before
|
|
|
|
|
// a user hits a confusing "rejected" line in the logs.
|
|
|
|
|
if cfg.Download.Transcode.Enabled {
|
|
|
|
|
if _, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err != nil {
|
|
|
|
|
log.Printf("[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS")
|
|
|
|
|
} else if _, err := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath); err != nil {
|
|
|
|
|
log.Printf("[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Wire sync client callbacks
|
|
|
|
|
sc := d.SyncClient()
|
|
|
|
|
sc.GetFreeSlots = manager.FreeSlots
|
|
|
|
|
sc.GetTaskStates = manager.TaskStates
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
d.GetActiveCount = manager.ActiveCount
|
2026-03-28 11:29:42 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Trigger immediate sync when a download slot frees up
|
|
|
|
|
manager.OnTaskDone = func() { d.TriggerSync() }
|
2026-03-28 11:29:42 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Wire: sync receives new tasks → submit to manager or handle stream
|
2026-03-28 11:29:42 +01:00
|
|
|
d.OnTasksClaimed = func(tasks []agent.Task) {
|
|
|
|
|
for _, t := range tasks {
|
feat(seed-file): unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.
Pieces:
- internal/agent/types.go: Task.FilePath (received from claim) +
StatusUpdate.InfoHash (sent back). Both serialise compatibly with
the matching Zod schemas in the Next.js sync route.
- internal/engine/seed_file.go: SeedFile(client, filePath, trackers)
builds the metainfo via metainfo.Info.BuildFromFilePath +
bencode.Marshal, then AddTorrent + DownloadAll() so anacrolix
hashes the file and flips pieces to "have" as it goes. The
libtorrent piece-size ladder is mirrored from wstracker-probe so
generated torrents are interoperable with mainstream clients.
SeedFileOnDownloader is the daemon-facing convenience wrapper —
bails loud when [downloads.webrtc].enabled = false instead of
silently producing a torrent no browser can find.
- internal/cmd/seed_file_handler.go: handleSeedFileTask invoked from
the existing OnTasksClaimed dispatcher in daemon.go for mode=
seed_file. Validates filePath, calls the engine helper, and pushes
the resulting info_hash via Client.ReportStatus. Failures (missing
file, WebRTC disabled, ffmpeg-style oddities) report status="failed"
+ errorMessage so the browser's WatchInBrowserButton can show the
reason instead of timing out at 60 s.
- internal/cmd/daemon.go: dispatcher learns the seed_file branch in
the same shape as the existing stream branch.
Tests (6 unit, all green):
- SeedFile rejects missing files + directories.
- SeedFile yields a deterministic info_hash for the same payload across
fresh clients (web client polls expecting this).
- SeedFileOnDownloader errors when WebRTC is disabled.
- chooseSeedPieceLength matches the ladder breakpoints.
- makeAnnounceList handles nil/empty/partial inputs.
Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
2026-05-06 16:28:01 +02:00
|
|
|
if t.Mode == "seed_file" {
|
|
|
|
|
// Browser asked us to wrap an arbitrary on-disk file as
|
|
|
|
|
// a single-file torrent + seed it via WebRTC. Runs in
|
|
|
|
|
// its own goroutine so a slow / failing seed can't
|
|
|
|
|
// stall the rest of the claim batch.
|
|
|
|
|
go handleSeedFileTask(t, torrentDl, agentClient)
|
|
|
|
|
} else if t.Mode == "stream" {
|
2026-04-07 16:19:01 +02:00
|
|
|
if isStreamingTask(t.ID) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-07 19:08:37 +02:00
|
|
|
cancelStreamContexts()
|
|
|
|
|
streamSrv.ClearFile()
|
2026-04-08 18:50:59 +02:00
|
|
|
streamCtx, streamCancel := context.WithCancel(ctx) //nolint:gosec // G118: cancel stored in registry
|
2026-04-07 16:19:01 +02:00
|
|
|
streamRegistry.mu.Lock()
|
|
|
|
|
streamRegistry.cancels[t.ID] = streamCancel
|
|
|
|
|
streamRegistry.mu.Unlock()
|
2026-04-07 19:08:37 +02:00
|
|
|
go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv)
|
2026-04-08 18:50:59 +02:00
|
|
|
} else {
|
2026-03-28 11:29:42 +01:00
|
|
|
manager.Submit(ctx, t)
|
2026-04-08 18:50:59 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wire: sync receives control signals → act on manager
|
|
|
|
|
d.OnControlAction = func(action, taskID string, deleteFiles bool) {
|
|
|
|
|
switch action {
|
|
|
|
|
case "cancel":
|
|
|
|
|
if deleteFiles {
|
|
|
|
|
manager.CancelAndDeleteFiles(taskID)
|
2026-03-28 11:29:42 +01:00
|
|
|
} else {
|
2026-04-08 18:50:59 +02:00
|
|
|
manager.CancelTask(taskID)
|
|
|
|
|
}
|
|
|
|
|
cancelStreamTask(taskID)
|
|
|
|
|
if streamSrv.CurrentTaskID() == taskID {
|
|
|
|
|
streamSrv.ClearFile()
|
|
|
|
|
}
|
|
|
|
|
case "pause":
|
|
|
|
|
manager.PauseTask(taskID)
|
|
|
|
|
cancelStreamTask(taskID)
|
|
|
|
|
if streamSrv.CurrentTaskID() == taskID {
|
|
|
|
|
streamSrv.ClearFile()
|
|
|
|
|
}
|
|
|
|
|
case "resume":
|
|
|
|
|
log.Printf("[%s] resume requested, triggering sync", agent.ShortID(taskID))
|
|
|
|
|
d.TriggerSync()
|
|
|
|
|
case "stream":
|
|
|
|
|
if streamSrv.CurrentTaskID() == taskID {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
task := manager.GetTask(taskID)
|
|
|
|
|
if task == nil || task.GetStreamURL() != "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
provider, err := torrentDl.GetStreamProvider(taskID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("[%s] stream failed: %v", agent.ShortID(taskID), err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cancelStreamContexts()
|
|
|
|
|
streamSrv.SetFile(provider, taskID)
|
|
|
|
|
task.SetStreamURL(streamSrv.URLsJSON())
|
|
|
|
|
log.Printf("[%s] streaming: %s", agent.ShortID(taskID), provider.FileName())
|
|
|
|
|
|
|
|
|
|
watchCtx, watchCancel := context.WithCancel(ctx) //nolint:gosec // G118
|
|
|
|
|
streamRegistry.mu.Lock()
|
|
|
|
|
streamRegistry.cancels["watch:"+taskID] = watchCancel
|
|
|
|
|
streamRegistry.mu.Unlock()
|
|
|
|
|
go engine.NewWatchReporter(agentClient, streamSrv, taskID).Run(watchCtx)
|
|
|
|
|
case "stop-stream":
|
|
|
|
|
cancelStreamTask(taskID)
|
|
|
|
|
if streamSrv.CurrentTaskID() == taskID {
|
|
|
|
|
streamSrv.ClearFile()
|
2026-03-28 11:29:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 16:35:12 +02:00
|
|
|
// Wire: sync receives file deletion requests from the server
|
|
|
|
|
if cfg.Library.AllowDelete && len(daemonCfg.ScanPaths) > 0 {
|
|
|
|
|
sc.OnDeleteFiles = func(items []agent.LibraryDeleteRequest) []int {
|
|
|
|
|
return library.DeleteFiles(items, daemonCfg.ScanPaths)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Wire: sync receives stream requests for completed downloads
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
d.OnStreamRequested = func(sr agent.StreamRequest) {
|
2026-04-07 19:08:37 +02:00
|
|
|
if streamSrv.CurrentTaskID() == sr.TaskID {
|
2026-04-08 18:50:59 +02:00
|
|
|
// Already serving — notify server it's ready
|
2026-04-07 23:29:09 +02:00
|
|
|
go func() {
|
2026-04-08 18:50:59 +02:00
|
|
|
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
|
2026-04-07 23:29:09 +02:00
|
|
|
TaskID: sr.TaskID,
|
|
|
|
|
StreamReady: true,
|
|
|
|
|
}); err != nil {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream ready re-notify failed: %v", agent.ShortID(sr.TaskID), err)
|
2026-04-07 23:29:09 +02:00
|
|
|
}
|
|
|
|
|
}()
|
2026-04-07 16:19:01 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 23:36:18 +02:00
|
|
|
filePath := filepath.Clean(sr.FilePath)
|
|
|
|
|
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
|
|
|
|
|
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
|
|
|
|
|
log.Printf("[%s] stream request rejected: path outside allowed dirs: %s", agent.ShortID(sr.TaskID), filePath)
|
2026-04-09 10:54:14 +02:00
|
|
|
go func() {
|
|
|
|
|
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
|
|
|
|
|
TaskID: sr.TaskID,
|
|
|
|
|
Status: "failed",
|
|
|
|
|
ErrorMessage: fmt.Sprintf("path outside allowed dirs: %s", filePath),
|
|
|
|
|
}); err != nil {
|
|
|
|
|
log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-04-08 23:36:18 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-03-31 16:55:50 +02:00
|
|
|
info, err := os.Stat(filePath)
|
|
|
|
|
if err != nil {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream request: file not found: %s", agent.ShortID(sr.TaskID), filePath)
|
2026-04-07 12:39:22 +02:00
|
|
|
go func() {
|
2026-04-08 18:50:59 +02:00
|
|
|
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
|
2026-04-07 12:39:22 +02:00
|
|
|
TaskID: sr.TaskID,
|
|
|
|
|
Status: "failed",
|
|
|
|
|
ErrorMessage: fmt.Sprintf("file not found: %s", filePath),
|
|
|
|
|
}); err != nil {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
|
2026-04-07 12:39:22 +02:00
|
|
|
}
|
|
|
|
|
}()
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:55:50 +02:00
|
|
|
if info.IsDir() {
|
|
|
|
|
found := engine.FindVideoFile(filePath)
|
|
|
|
|
if found == "" {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream request: no video file in directory: %s", agent.ShortID(sr.TaskID), filePath)
|
2026-04-07 12:39:22 +02:00
|
|
|
go func() {
|
2026-04-08 18:50:59 +02:00
|
|
|
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
|
2026-04-07 12:39:22 +02:00
|
|
|
TaskID: sr.TaskID,
|
|
|
|
|
Status: "failed",
|
|
|
|
|
ErrorMessage: fmt.Sprintf("no video file in directory: %s", filePath),
|
|
|
|
|
}); err != nil {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
|
2026-04-07 12:39:22 +02:00
|
|
|
}
|
|
|
|
|
}()
|
2026-03-31 16:55:50 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
filePath = found
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] resolved directory to video file: %s", agent.ShortID(sr.TaskID), filepath.Base(filePath))
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 19:08:37 +02:00
|
|
|
cancelStreamContexts()
|
|
|
|
|
streamSrv.SetFile(engine.NewDiskFileProvider(filePath), sr.TaskID)
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] streaming from disk: %s → %s", agent.ShortID(sr.TaskID), filepath.Base(filePath), streamSrv.URL())
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
watchCtx, watchCancel := context.WithCancel(ctx) //nolint:gosec // G118
|
2026-04-07 23:29:09 +02:00
|
|
|
streamRegistry.mu.Lock()
|
|
|
|
|
streamRegistry.cancels["watch:"+sr.TaskID] = watchCancel
|
|
|
|
|
streamRegistry.mu.Unlock()
|
|
|
|
|
go engine.NewWatchReporter(agentClient, streamSrv, sr.TaskID).Run(watchCtx)
|
2026-04-01 12:16:45 +02:00
|
|
|
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
go func() {
|
2026-04-08 18:50:59 +02:00
|
|
|
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
|
2026-04-07 19:08:37 +02:00
|
|
|
TaskID: sr.TaskID,
|
|
|
|
|
StreamReady: true,
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
}); err != nil {
|
2026-04-08 18:50:59 +02:00
|
|
|
log.Printf("[%s] stream ready report failed: %v", agent.ShortID(sr.TaskID), err)
|
feat(debrid): add HTTPS downloader for debrid direct URLs
DebridDownloader receives directUrl from the server and downloads via
plain HTTPS with progress reporting, resume (Range), and pause/cancel.
- Add DirectURL, DirectFileName to agent Task and engine Task types
- Implement DebridDownloader: HTTPS download with progress, resume, cancel
- HTTP client with 30s ResponseHeaderTimeout
- Safe shortID helper to prevent slice panic on short IDs
- Validate 416 against Content-Range server size for resume integrity
- Register debridDl in daemon and one-shot download command
- Tests: available, download, resume, cancel, pause, fallback filename,
expired URL (410), unauthorized (401), shutdown, task propagation
2026-03-28 18:09:34 +01:00
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:12:38 +02:00
|
|
|
// Wire: sync receives custom WebRTC streaming session requests.
|
|
|
|
|
// Each session is a one-shot browser↔daemon DataChannel. Validate the
|
|
|
|
|
// FilePath against allowed dirs to prevent path traversal abuse from a
|
|
|
|
|
// compromised server, then spawn the pion peer in its own goroutine.
|
|
|
|
|
d.OnWebRTCSession = func(sess agent.WebRTCSession) {
|
|
|
|
|
if webrtcRegistry.has(sess.SessionID) {
|
|
|
|
|
return // already running
|
|
|
|
|
}
|
|
|
|
|
filePath := sess.FilePath
|
|
|
|
|
if filePath == "" {
|
|
|
|
|
log.Printf("webrtc session %s rejected: empty file path", agent.ShortID(sess.SessionID))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
filePath = filepath.Clean(filePath)
|
|
|
|
|
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
|
|
|
|
|
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
|
|
|
|
|
log.Printf("webrtc session %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("webrtc session %s rejected: no video file in dir %s",
|
|
|
|
|
agent.ShortID(sess.SessionID), filePath)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
filePath = found
|
|
|
|
|
}
|
2026-05-07 16:10:22 +02:00
|
|
|
|
2026-05-08 12:44:06 +02:00
|
|
|
// Branch on transport: HLS sessions only need ffmpeg + StreamServer,
|
|
|
|
|
// not a WebRTC peer, so they must bypass the WebRTC.Enabled gate.
|
|
|
|
|
// Default ("" or "webrtc") runs the DataChannel pipeline and requires it.
|
2026-05-07 16:10:22 +02:00
|
|
|
if strings.EqualFold(sess.Transport, "hls") {
|
|
|
|
|
tcRuntime := buildTranscodeRuntime(ctx, cfg)
|
|
|
|
|
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
|
|
|
|
|
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 12:39:07 +02:00
|
|
|
hlsCtx, hlsCancel := context.WithCancel(ctx)
|
|
|
|
|
webrtcRegistry.add(sess.SessionID, hlsCancel)
|
2026-05-07 16:10:22 +02:00
|
|
|
hlsCfg := engine.HLSSessionConfig{
|
|
|
|
|
SessionID: sess.SessionID,
|
|
|
|
|
SourcePath: filePath,
|
|
|
|
|
FileName: sess.FileName,
|
|
|
|
|
Quality: sess.Quality,
|
|
|
|
|
AudioIndex: sess.AudioIndex,
|
|
|
|
|
Transcode: tcRuntime,
|
|
|
|
|
}
|
2026-05-08 12:39:07 +02:00
|
|
|
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
2026-05-07 16:10:22 +02:00
|
|
|
if err != nil {
|
2026-05-08 12:39:07 +02:00
|
|
|
webrtcRegistry.remove(sess.SessionID)
|
|
|
|
|
hlsCancel()
|
2026-05-07 16:10:22 +02:00
|
|
|
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
streamSrv.HLS().Register(hsess)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 12:44:06 +02:00
|
|
|
// Non-HLS transport requires WebRTC peer support.
|
|
|
|
|
if !cfg.Download.WebRTC.Enabled {
|
|
|
|
|
log.Printf("webrtc session %s rejected: webrtc disabled in config", agent.ShortID(sess.SessionID))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:12:38 +02:00
|
|
|
sessCtx, sessCancel := context.WithCancel(ctx) //nolint:gosec // G118 cancel stored in registry
|
|
|
|
|
webrtcRegistry.add(sess.SessionID, sessCancel)
|
|
|
|
|
go func() {
|
|
|
|
|
defer func() {
|
|
|
|
|
webrtcRegistry.remove(sess.SessionID)
|
|
|
|
|
sessCancel()
|
|
|
|
|
}()
|
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
|
|
|
tcRuntime := buildTranscodeRuntime(ctx, cfg)
|
2026-05-06 23:12:38 +02:00
|
|
|
runCfg := engine.WebRTCStreamConfig{
|
|
|
|
|
SessionID: sess.SessionID,
|
|
|
|
|
FilePath: filePath,
|
|
|
|
|
FileName: sess.FileName,
|
|
|
|
|
FileSize: sess.FileSize,
|
2026-05-07 10:13:45 +02:00
|
|
|
Quality: sess.Quality,
|
2026-05-06 23:12:38 +02:00
|
|
|
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
|
|
|
|
Signal: agentClient,
|
|
|
|
|
Logger: stdLogger{},
|
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
|
|
|
Transcode: tcRuntime,
|
2026-05-06 23:12:38 +02:00
|
|
|
}
|
|
|
|
|
log.Printf("[wrtc %s] starting session: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
|
|
|
|
|
if err := engine.RunWebRTCStream(sessCtx, runCfg); err != nil {
|
|
|
|
|
if sessCtx.Err() == nil {
|
|
|
|
|
log.Printf("[wrtc %s] ended: %v", agent.ShortID(sess.SessionID), err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Periodic DHT node persistence (every 5 min)
|
2026-03-29 19:09:51 +02:00
|
|
|
go func() {
|
|
|
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
torrentDl.SaveDhtNodes()
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-05-07 23:55:05 +02:00
|
|
|
// Periodic HLS session sweeper (every 5 min). Closes sessions whose last
|
|
|
|
|
// segment fetch was over 30 min ago — kills the orphan ffmpeg + removes
|
|
|
|
|
// the per-session tmpdir, so a tab that died mid-stream doesn't leak
|
|
|
|
|
// disk space until daemon shutdown.
|
|
|
|
|
go func() {
|
|
|
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if n := streamSrv.HLS().SweepIdle(); n > 0 {
|
|
|
|
|
log.Printf("[hls] swept %d idle session(s)", n)
|
|
|
|
|
}
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Start auto-scan goroutine
|
2026-04-10 16:35:12 +02:00
|
|
|
scanPaths := daemonCfg.ScanPaths
|
2026-04-10 11:46:20 +02:00
|
|
|
if len(scanPaths) > 0 && cfg.Library.AutoScan {
|
2026-03-29 20:22:15 +02:00
|
|
|
scanInterval := 24 * time.Hour
|
|
|
|
|
if cfg.Library.ScanInterval != "" {
|
|
|
|
|
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
|
|
|
|
scanInterval = parsed
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 11:46:20 +02:00
|
|
|
go runAutoScan(ctx, cfg, scanInterval, agentClient, d.ScanNow, scanPaths)
|
2026-03-29 20:22:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Start reporter only for stream task handling
|
|
|
|
|
go reporter.Run(ctx)
|
|
|
|
|
|
|
|
|
|
// Start daemon (blocks — runs sync loop)
|
2026-03-28 11:29:42 +01:00
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
errCh <- d.Run(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
2026-04-07 19:08:37 +02:00
|
|
|
// Start idle guard for the persistent stream server
|
|
|
|
|
go startIdleGuard(ctx, streamSrv)
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
// Signal handling
|
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
// Wait for signal or error
|
|
|
|
|
select {
|
|
|
|
|
case sig := <-sigCh:
|
|
|
|
|
fmt.Printf("\n Received %s, shutting down...\n", sig)
|
2026-04-07 19:08:37 +02:00
|
|
|
cancelStreamContexts()
|
2026-05-06 23:12:38 +02:00
|
|
|
cancelAllWebRTCSessions()
|
2026-04-07 19:08:37 +02:00
|
|
|
streamSrv.Shutdown(context.Background())
|
2026-03-28 11:29:42 +01:00
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
// Give active downloads 30s to finish
|
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
|
|
|
defer shutdownCancel()
|
|
|
|
|
manager.Shutdown(shutdownCtx)
|
|
|
|
|
|
2026-04-08 18:50:59 +02:00
|
|
|
d.Deregister()
|
2026-03-28 11:29:42 +01:00
|
|
|
fmt.Println(" Daemon stopped.")
|
|
|
|
|
return nil
|
|
|
|
|
|
|
|
|
|
case err := <-errCh:
|
2026-04-07 19:08:37 +02:00
|
|
|
cancelStreamContexts()
|
2026-05-06 23:12:38 +02:00
|
|
|
cancelAllWebRTCSessions()
|
2026-04-07 19:08:37 +02:00
|
|
|
streamSrv.Shutdown(context.Background())
|
2026-03-28 11:29:42 +01:00
|
|
|
cancel()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 23:36:18 +02:00
|
|
|
// isAllowedStreamPath checks that filePath is within one of the directories
|
|
|
|
|
// the daemon is configured to manage. This defends against a compromised API
|
|
|
|
|
// server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest.
|
|
|
|
|
// isAllowedStreamPath reports whether filePath is contained within one of the
|
|
|
|
|
// allowedDirs. filePath must already be cleaned (filepath.Clean) by the caller.
|
|
|
|
|
// This defends against a compromised API server sending a path traversal payload.
|
|
|
|
|
func isAllowedStreamPath(filePath string, allowedDirs ...string) bool {
|
|
|
|
|
for _, dir := range allowedDirs {
|
|
|
|
|
if dir == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
rel, err := filepath.Rel(filepath.Clean(dir), filePath)
|
|
|
|
|
if err == nil && !strings.HasPrefix(rel, "..") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 11:29:42 +01:00
|
|
|
func formatSpeedLog(bps int64) string {
|
|
|
|
|
switch {
|
|
|
|
|
case bps >= 1024*1024*1024:
|
|
|
|
|
return fmt.Sprintf("%.1f GB/s", float64(bps)/(1024*1024*1024))
|
|
|
|
|
case bps >= 1024*1024:
|
|
|
|
|
return fmt.Sprintf("%.1f MB/s", float64(bps)/(1024*1024))
|
|
|
|
|
case bps >= 1024:
|
|
|
|
|
return fmt.Sprintf("%.0f KB/s", float64(bps)/1024)
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Sprintf("%d B/s", bps)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-29 20:22:15 +02:00
|
|
|
|
2026-04-07 11:36:42 +02:00
|
|
|
// runAutoScan runs a library scan + sync on a timer or on-demand via scanNow channel.
|
2026-04-10 11:46:20 +02:00
|
|
|
// It scans all provided paths and syncs each independently so stale-item cleanup
|
|
|
|
|
// is scoped to the correct directory prefix on the server.
|
|
|
|
|
func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}, scanPaths []string) {
|
|
|
|
|
log.Printf("[auto-scan] enabled: every %s, paths: %v", interval, scanPaths)
|
2026-03-29 20:22:15 +02:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-time.After(30 * time.Second):
|
2026-04-07 11:36:42 +02:00
|
|
|
case <-scanNow:
|
2026-03-29 20:22:15 +02:00
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doScan := func() {
|
2026-03-29 20:32:08 +02:00
|
|
|
defer func() {
|
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
|
log.Printf("[auto-scan] panic recovered: %v", r)
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-04-10 11:46:20 +02:00
|
|
|
log.Printf("[auto-scan] starting scan of %v", scanPaths)
|
2026-03-29 20:22:15 +02:00
|
|
|
|
|
|
|
|
existing, _ := library.LoadCache()
|
|
|
|
|
|
|
|
|
|
workers := cfg.Library.Workers
|
|
|
|
|
if workers == 0 {
|
|
|
|
|
workers = 8
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 11:46:20 +02:00
|
|
|
scanOpts := library.ScanOptions{
|
2026-03-29 20:22:15 +02:00
|
|
|
Workers: workers,
|
|
|
|
|
FFprobePath: cfg.Library.FFprobePath,
|
|
|
|
|
Incremental: existing != nil,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 11:46:20 +02:00
|
|
|
// Scan each path independently and sync per path so the server can
|
|
|
|
|
// scope stale-item deletion to the correct directory prefix.
|
|
|
|
|
const batchSize = 100
|
|
|
|
|
totalSynced := 0
|
|
|
|
|
var mergedItems []library.LibraryItem
|
2026-03-29 20:22:15 +02:00
|
|
|
|
2026-04-10 11:46:20 +02:00
|
|
|
for _, scanPath := range scanPaths {
|
|
|
|
|
cache, err := library.Scan(ctx, scanPath, existing, scanOpts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("[auto-scan] scan failed for %s: %v", scanPath, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
mergedItems = append(mergedItems, cache.Items...)
|
|
|
|
|
|
|
|
|
|
items := library.BuildSyncItems(cache)
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
log.Printf("[auto-scan] no items under %s", scanPath)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
for i := 0; i < len(items); i += batchSize {
|
|
|
|
|
end := i + batchSize
|
|
|
|
|
if end > len(items) {
|
|
|
|
|
end = len(items)
|
|
|
|
|
}
|
|
|
|
|
isLast := end >= len(items)
|
|
|
|
|
|
|
|
|
|
_, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
|
|
|
|
Items: items[i:end],
|
|
|
|
|
ScanPath: scanPath,
|
|
|
|
|
IsLastBatch: isLast,
|
|
|
|
|
SyncStartedAt: syncStartedAt,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("[auto-scan] sync failed for %s: %v", scanPath, err)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
totalSynced += len(items)
|
2026-03-29 20:22:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 11:46:20 +02:00
|
|
|
// Save merged cache for incremental scanning next time.
|
|
|
|
|
if len(mergedItems) > 0 {
|
|
|
|
|
mergedCache := &library.LibraryCache{
|
|
|
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339),
|
|
|
|
|
Path: scanPaths[0],
|
|
|
|
|
Items: mergedItems,
|
2026-03-29 20:22:15 +02:00
|
|
|
}
|
2026-04-10 11:46:20 +02:00
|
|
|
if err := library.SaveCache(mergedCache); err != nil {
|
|
|
|
|
log.Printf("[auto-scan] save cache failed: %v", err)
|
2026-03-29 20:22:15 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 11:46:20 +02:00
|
|
|
log.Printf("[auto-scan] synced %d items across %d path(s)", totalSynced, len(scanPaths))
|
2026-03-29 20:22:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doScan()
|
|
|
|
|
|
|
|
|
|
ticker := time.NewTicker(interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
doScan()
|
2026-04-07 11:36:42 +02:00
|
|
|
case <-scanNow:
|
|
|
|
|
log.Printf("[auto-scan] on-demand scan triggered")
|
|
|
|
|
ticker.Reset(interval)
|
|
|
|
|
doScan()
|
2026-03-29 20:22:15 +02:00
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|