feat(stream)!: retire WebRTC, HLS-only, bump 0.9.4
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

Drops the custom WebRTC DataChannel pipeline + pion deps + WSS signaling
client + wire framing. Every in-browser playback now uses HLS over HTTP
from the daemon (Tailscale/LAN/UPnP). Browser P2P never re-enabled.

Wire renames (incompatible with web < 2026-05-26): agent.WebRTCSession
=> agent.StreamSession, SyncResponse.WebRTCSessions (JSON: webrtcSessions)
=> StreamSessions (JSON: streamSessions). MIN_AGENT_VERSION is bumped
to 0.9.4 on the web side so older agents see an upgrade card.

Also fixes the libx264 'VBV bitrate > level limit' abort by clamping
the encoder bitrate to the effective output height instead of the
requested label (carried over from the prior 0.9.3 unreleased work).

The seed_file vertical (mode=seed_file handler + engine.SeedFile) was
retired with the in-browser P2P player. [downloads.webrtc] config block
deleted; existing TOML files with the section still parse fine.
This commit is contained in:
Deivid Soto 2026-05-26 18:04:35 +02:00
parent 9176e877eb
commit ca7de23a56
33 changed files with 207 additions and 2854 deletions

View file

@ -86,14 +86,11 @@ jobs:
run: |
# Threshold applies only to engine and agent — cmd contains interactive UI
# commands (config menus, daemon, auth browser) that are not unit-testable.
# WebRTC files are excluded: deprecated, slated for removal in 0.9.0.
go test -race -coverprofile=coverage-core.out -covermode=atomic \
./internal/engine/... \
./internal/agent/...
# Strip webrtc lines from the profile before computing the threshold.
grep -v '/internal/engine/webrtc' coverage-core.out > coverage-core-filtered.out
COVERAGE=$(go tool cover -func=coverage-core-filtered.out | grep ^total | awk '{print $3}' | tr -d '%')
echo "Coverage on engine+agent (excluding webrtc): ${COVERAGE}%"
COVERAGE=$(go tool cover -func=coverage-core.out | grep ^total | awk '{print $3}' | tr -d '%')
echo "Coverage on engine+agent: ${COVERAGE}%"
python3 -c "
coverage = float('${COVERAGE}')
threshold = 50.0

View file

@ -5,6 +5,37 @@ 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).
## [0.9.4] - 2026-05-26
### Removed
- **streaming**: retire the custom WebRTC DataChannel pipeline. The daemon no
longer ships pion/webrtc, the WSS signaling client, or the wire framing
package — every in-browser session now uses HLS over HTTP from the daemon
(Tailscale / LAN / UPnP). Browser P2P (WebTorrent) bytes never re-enabled.
- **config**: `[downloads.webrtc]` block removed from the TOML schema; existing
config files with the section parse cleanly because go-toml ignores unknown
sections.
- **seed_file**: `mode=seed_file` task handler + `engine.SeedFile` helper
dropped — the last in-browser caller was retired with the WebRTC player.
- **wstracker-probe**: standalone probe binary removed.
### Changed
- **agent wire**: `SyncResponse.WebRTCSessions` (JSON: `webrtcSessions`) renamed
to `StreamSessions` (JSON: `streamSessions`). The Go type `agent.WebRTCSession`
is now `agent.StreamSession`. Wire-incompatible with web < 2026-05-26.
- **torrent**: `buildMagnet` no longer accepts an `extraTrackers` variadic —
the default tracker list is the only set used.
### Fixed
- **hls**: clamp the ffmpeg `-b:v` to the bitrate cap derived from the EFFECTIVE
output height instead of the requested quality. Previously asking for "2160p"
on a 1080p source overshot the H.264 level we resolved from the effective
height (4.0, max 20 Mbps) and made libx264 abort with
`VBV bitrate > level limit`.
## [0.9.2] - 2026-05-21
### Added

View file

@ -434,24 +434,12 @@ country = "US"
### Streaming reference
The in-browser player on torrentclaw.com streams from the daemon over WebRTC
(low-latency P2P) or HLS (HTTP fragments + ffmpeg transcode for codecs the
browser can't decode natively). Both are enabled by default — a fresh install
"just works" without editing the TOML. Disable surgically only if you have a
reason.
The in-browser player on torrentclaw.com streams from the daemon over HLS
(HTTP fragments + ffmpeg transcode for codecs the browser can't decode
natively). Enabled by default — a fresh install "just works" without editing
the TOML.
```toml
[downloads.webrtc]
enabled = true # master switch
trackers = ["wss://tracker.torrentclaw.com"] # signaling trackers
stun_servers = [ # NAT traversal
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
]
turn_servers = [] # optional TURN relays
turn_user = ""
turn_pass = ""
[downloads.transcode]
enabled = true # master switch
hw_accel = "auto" # auto | none | nvenc | qsv | vaapi | videotoolbox
@ -462,16 +450,6 @@ max_height = 0 # 0 = no cap; e.g. 720 forces 720p max
max_concurrent = 2 # max simultaneous ffmpeg processes
```
#### `[downloads.webrtc]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Browser↔daemon WebRTC peer for the in-browser P2P player. Disable to skip WebRTC tracker signalling (saves ~5MB RAM, blocks WebRTC streaming — HLS still works). |
| `trackers` | `[]string` | `["wss://tracker.torrentclaw.com"]` | Signaling trackers for peer discovery. |
| `stun_servers` | `[]string` | Google public STUN ×2 | ICE candidate gathering. |
| `turn_servers` | `[]string` | `[]` | Optional TURN relays for symmetric-NAT users. |
| `turn_user` / `turn_pass` | string | `""` | Credentials for authed TURN servers. Applied to all `turn_servers`. |
#### `[downloads.transcode]`
| Key | Type | Default | Notes |

View file

@ -72,7 +72,7 @@ Docker Hub vulnerability count:
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
**functional dependency** — the WebRTC/HLS transcode pipeline shells out to
**functional dependency** — the HLS transcode pipeline shells out to
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
### Accepted risk and policy
@ -100,7 +100,7 @@ Recommended additions for exposed deployments:
- no-new-privileges:true
```
If you do not need WebRTC/HLS transcoding, you can run with transcoding disabled to
If you do not need HLS transcoding, you can run with transcoding disabled to
avoid feeding untrusted media to ffmpeg at all.
## Disclosure Policy

View file

@ -1,268 +0,0 @@
// wstracker-probe — connects to a WebSocket BitTorrent tracker and either
// (a) advertises a fake info_hash to verify announce signalling, or
// (b) seeds a real file via the WebTorrent protocol so a browser
// webtorrent.js client can fetch it for end-to-end verification.
//
// Modes:
//
// wstracker-probe -tracker wss://tracker.torrentclaw.com
// Announces a random info_hash; exits 0 on TrackerAnnounceSuccessful.
//
// wstracker-probe -tracker wss://… -seed /path/to/file.mp4
// Builds a single-file torrent in memory, seeds forever, prints the
// magnet (with the WSS tracker injected). Ctrl-C to stop.
//
// Useful for browser ↔ unarr e2e — point a webtorrent.js page at the
// printed magnet and the player should pull pieces via WebRTC data channel.
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
)
func main() {
tracker := flag.String("tracker", "wss://tracker.torrentclaw.com", "WSS tracker URL to probe")
timeout := flag.Duration("timeout", 30*time.Second, "max wait for successful announce (ignored in -seed mode)")
seedPath := flag.String("seed", "", "path to a file to seed (single-file torrent). When set, runs forever instead of exiting on first announce.")
flag.Parse()
if *seedPath != "" {
runSeeder(*seedPath, *tracker)
return
}
runProbe(*tracker, *timeout)
}
// runProbe — single random-hash announce, exits on success/error/timeout.
func runProbe(trackerURL string, timeout time.Duration) {
tmp, err := os.MkdirTemp("", "wstracker-probe-*")
if err != nil {
log.Fatalf("temp dir: %v", err)
}
defer os.RemoveAll(tmp)
cfg := baseClientConfig(tmp)
annSuccess := make(chan struct{}, 1)
annError := make(chan error, 1)
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive // peer events are noise for tracker probe
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[probe] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[probe] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[probe] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
select {
case annSuccess <- struct{}{}:
default:
}
case torrent.TrackerAnnounceError:
fmt.Printf("[probe] tracker announce ERROR: %s ih=%s err=%v\n", e.Url, e.InfoHash, e.Error)
select {
case annError <- e.Error:
default:
}
case torrent.TrackerDisconnected:
fmt.Printf("[probe] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
var ih [20]byte
if _, err := rand.Read(ih[:]); err != nil {
log.Fatalf("random info_hash: %v", err)
}
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%x&tr=%s", ih, trackerURL)
fmt.Printf("[probe] tracker=%s info_hash=%x timeout=%s\n", trackerURL, ih, timeout)
t, err := client.AddMagnet(magnet)
if err != nil {
log.Fatalf("add magnet: %v", err)
}
defer t.Drop()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case <-annSuccess:
fmt.Println("[probe] OK — tracker announce succeeded")
os.Exit(0)
case err := <-annError:
fmt.Printf("[probe] FAIL — tracker announce error: %v\n", err)
os.Exit(1)
case <-ctx.Done():
fmt.Printf("[probe] FAIL — timeout after %s\n", timeout)
os.Exit(2)
}
}
// runSeeder — builds a single-file torrent for the given path, adds it to
// a WebTorrent-enabled client, and seeds until SIGINT/SIGTERM.
func runSeeder(filePath, trackerURL string) {
abs, err := filepath.Abs(filePath)
if err != nil {
log.Fatalf("resolve seed path: %v", err)
}
st, err := os.Stat(abs)
if err != nil {
log.Fatalf("stat seed file: %v", err)
}
if st.IsDir() {
log.Fatalf("-seed currently supports a single file, not a directory: %s", abs)
}
dataDir := filepath.Dir(abs)
// Build single-file torrent metadata.
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
log.Fatalf("build info from file: %v", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
log.Fatalf("marshal info: %v", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: metainfo.AnnounceList{{trackerURL}},
CreatedBy: "wstracker-probe",
}
ih := mi.HashInfoBytes()
cfg := baseClientConfig(dataDir)
cfg.Seed = true
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[seed] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[seed] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[seed] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
case torrent.TrackerAnnounceError:
fmt.Printf("[seed] tracker announce ERROR: %s err=%v\n", e.Url, e.Error)
case torrent.TrackerDisconnected:
fmt.Printf("[seed] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
t, err := client.AddTorrent(mi)
if err != nil {
log.Fatalf("add torrent: %v", err)
}
t.DownloadAll()
dn := url.QueryEscape(info.Name)
enc := url.QueryEscape(trackerURL)
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s&dn=%s&tr=%s", ih.HexString(), dn, enc)
fmt.Printf("[seed] file=%s size=%d bytes piece_length=%d\n", abs, st.Size(), info.PieceLength)
fmt.Printf("[seed] info_hash=%s\n", ih.HexString())
fmt.Printf("[seed] magnet=%s\n", magnet)
fmt.Println("[seed] seeding via WebRTC. Ctrl-C to stop.")
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
statTicker := time.NewTicker(5 * time.Second)
defer statTicker.Stop()
for {
select {
case <-statTicker.C:
s := t.Stats()
fmt.Printf("[seed] peers=%d uploaded=%d bytes seeders=%d leechers=%d\n",
s.ActivePeers, s.BytesWrittenData.Int64(),
s.ConnectedSeeders, s.ActivePeers-s.ConnectedSeeders)
case <-stop:
fmt.Println("[seed] stopping")
return
}
}
}
// baseClientConfig — shared anacrolix client config for both modes.
// WebTorrent is the only transport enabled; TCP/uTP/DHT/IPv6 are disabled
// to keep the moving parts to the minimum required for a WSS-only test.
func baseClientConfig(dataDir string) *torrent.ClientConfig {
cfg := torrent.NewDefaultClientConfig()
cfg.DataDir = dataDir
cfg.DefaultStorage = storage.NewMMap(dataDir)
cfg.NoUpload = false
cfg.DisableTCP = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.NoDHT = true
cfg.NoDefaultPortForwarding = true
cfg.ListenPort = 0
cfg.Logger = alog.Default.FilterLevel(alog.Critical)
cfg.DisableWebtorrent = false
cfg.ICEServerList = []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
{URLs: []string{"stun:stun1.l.google.com:19302"}},
}
return cfg
}
// chooseSeedPieceLength picks a sane piece size for a given file size.
// Mirrors the libtorrent / qBittorrent ladder so the resulting torrent
// is interoperable with mainstream clients.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024: // < 4 MiB
return 16 * 1024 // 16 KiB
case size < 64*1024*1024: // < 64 MiB
return 64 * 1024 // 64 KiB
case size < 512*1024*1024: // < 512 MiB
return 256 * 1024 // 256 KiB
case size < 4*1024*1024*1024: // < 4 GiB
return 1024 * 1024 // 1 MiB
default:
return 4 * 1024 * 1024 // 4 MiB
}
}

2
go.mod
View file

@ -13,7 +13,6 @@ require (
github.com/google/uuid v1.6.0
github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4
github.com/pion/webrtc/v4 v4.2.11
github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.43.0
@ -107,6 +106,7 @@ require (
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.11 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

View file

@ -38,7 +38,7 @@ type Daemon struct {
// Callbacks — set by cmd/daemon.go before calling Run.
OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
@ -210,9 +210,9 @@ func (d *Daemon) Run(ctx context.Context) error {
d.OnStreamRequested(req)
}
}
d.sync.OnWebRTCSession = func(sess WebRTCSession) {
if d.OnWebRTCSession != nil {
d.OnWebRTCSession(sess)
d.sync.OnStreamSession = func(sess StreamSession) {
if d.OnStreamSession != nil {
d.OnStreamSession(sess)
}
}
d.sync.OnUpgrade = func(version string) {

View file

@ -1,258 +0,0 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// SignalRole identifies who produced a signalling message. The opposite role
// receives it.
type SignalRole string
const (
SignalRoleBrowser SignalRole = "browser"
SignalRoleAgent SignalRole = "agent"
)
// SignalMessageType matches the server-side z.enum on
// /api/internal/stream/signal/[sessionId] route.
type SignalMessageType string
const (
SignalMsgOffer SignalMessageType = "offer"
SignalMsgAnswer SignalMessageType = "answer"
SignalMsgCandidate SignalMessageType = "candidate"
SignalMsgCandidateEnd SignalMessageType = "candidate-end"
SignalMsgBye SignalMessageType = "bye"
)
// SignalMessage mirrors the bus envelope on the web side.
type SignalMessage struct {
From SignalRole `json:"from"`
Type SignalMessageType `json:"type"`
Payload string `json:"payload"`
TS int64 `json:"ts"`
}
// PostSignal enqueues a signalling message produced by this agent. The
// browser receives it on its next SSE event push.
func (c *Client) PostSignal(ctx context.Context, sessionID string, msg SignalMessage) error {
body := map[string]any{
"from": string(SignalRoleAgent),
"type": string(msg.Type),
"payload": msg.Payload,
}
path := fmt.Sprintf("/api/internal/stream/signal/%s", sessionID)
return c.doPost(ctx, path, body, &struct {
OK bool `json:"ok"`
}{})
}
// SignalEventStream wraps an open SSE connection. Read messages from Events()
// until the channel closes (server timeout or context cancel). Always defer
// Close() to release the underlying response body.
type SignalEventStream struct {
resp *http.Response
cancel context.CancelFunc
events chan SignalMessage
errs chan error
done chan struct{}
}
// Events streams browser-produced messages addressed to the agent.
// The channel closes when the SSE connection ends; the caller should then
// call Close() and reopen if it wants to keep listening.
func (s *SignalEventStream) Events() <-chan SignalMessage { return s.events }
// Err returns the terminating error (if any) once Events() has closed.
func (s *SignalEventStream) Err() error {
select {
case err := <-s.errs:
return err
default:
return nil
}
}
// Close cancels the underlying HTTP request and waits for the reader goroutine
// to drain. Safe to call more than once.
func (s *SignalEventStream) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.resp != nil {
s.resp.Body.Close()
}
<-s.done
return nil
}
// OpenSignalStream opens a long-lived SSE connection to the signal events
// endpoint. Caller MUST cancel ctx (or call Close()) to free resources.
//
// The server caps each response at ~25 s; OpenSignalStream surfaces the
// disconnect by closing the events channel. Caller should reopen until the
// session ends.
func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*SignalEventStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
url := fmt.Sprintf("%s/api/internal/stream/signal/%s/events", c.baseURL(), sessionID)
req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Cache-Control", "no-cache")
// Use a per-call client with no timeout (SSE connections are long).
sseClient := &http.Client{}
resp, err := sseClient.Do(req)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
resp.Body.Close()
cancel()
return nil, fmt.Errorf("open signal stream: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
stream := &SignalEventStream{
resp: resp,
cancel: cancel,
events: make(chan SignalMessage, 8),
errs: make(chan error, 1),
done: make(chan struct{}),
}
go stream.read()
return stream, nil
}
// sseMaxLineBytes caps the size of a single SSE line. Real signalling lines
// are JSON payloads of a few hundred bytes; 256 KiB is generous enough to
// survive a future schema bump but small enough that a hostile or buggy
// server cannot grow daemon memory by streaming a single line forever.
const sseMaxLineBytes = 256 * 1024
// sseMaxEventBytes caps the total bytes buffered across the lines of one
// SSE event. Without a cap, a peer could send unbounded `data:` continuation
// lines and OOM the daemon between blank-line dispatches.
const sseMaxEventBytes = 1024 * 1024
func (s *SignalEventStream) read() {
defer close(s.done)
defer close(s.events)
scanner := bufio.NewScanner(s.resp.Body)
scanner.Buffer(make([]byte, 16*1024), sseMaxLineBytes)
var dataBuf bytes.Buffer
var eventName string
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line == "" {
// End of an event — dispatch if we have data.
if dataBuf.Len() == 0 {
eventName = ""
continue
}
if eventName == "" || eventName == "signal" {
var msg SignalMessage
if err := json.Unmarshal(dataBuf.Bytes(), &msg); err == nil {
select {
case s.events <- msg:
case <-s.resp.Request.Context().Done():
return
}
}
}
dataBuf.Reset()
eventName = ""
continue
}
if strings.HasPrefix(line, ":") {
// SSE comment (heartbeat); ignore.
continue
}
if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(line[len("event:"):])
continue
}
if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(line[len("data:"):])
// Refuse to grow the event buffer past the cap. Reset so a
// well-formed event after the offender can still be parsed,
// and surface an error so SignalLoop reconnects.
if dataBuf.Len()+len(payload)+1 > sseMaxEventBytes {
dataBuf.Reset()
eventName = ""
select {
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", sseMaxEventBytes):
default:
}
return
}
if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n')
}
dataBuf.WriteString(payload)
continue
}
// id:, retry:, anything else — ignore for now.
}
if err := scanner.Err(); err != nil {
select {
case s.errs <- err:
default:
}
}
}
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
// onMessage is called for every browser-produced message. Returns when ctx is
// cancelled. Reconnect backoff is fixed at 1 s — the server already paces
// reconnects with `retry: 1500` headers so churn is bounded.
func (c *Client) SignalLoop(ctx context.Context, sessionID string, onMessage func(SignalMessage)) error {
for ctx.Err() == nil {
stream, err := c.OpenSignalStream(ctx, sessionID)
if err != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
continue
}
for msg := range stream.Events() {
onMessage(msg)
}
streamErr := stream.Err()
stream.Close()
if ctx.Err() != nil {
return ctx.Err()
}
// Server closes the SSE every ~25 s; reconnect immediately.
// Hard error → small backoff so we don't hammer.
if streamErr != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
return ctx.Err()
}

View file

@ -1,196 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeSSEServer streams a fixed set of SSE events then closes the connection.
func fakeSSEServer(t *testing.T, msgs []SignalMessage, holdOpenAfter bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("server: ResponseWriter is not a Flusher")
}
fmt.Fprint(w, "retry: 1500\n\n")
flusher.Flush()
for _, m := range msgs {
data, _ := json.Marshal(m)
fmt.Fprintf(w, "id: %d\nevent: signal\ndata: %s\n\n", m.TS, data)
flusher.Flush()
}
// Send a heartbeat comment to verify it's ignored.
fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
if holdOpenAfter {
// Hold the connection until the client disconnects so the test can
// exercise stream.Close().
<-r.Context().Done()
}
}))
}
func TestSignalStreamReadsMessages(t *testing.T) {
want := []SignalMessage{
{From: SignalRoleBrowser, Type: SignalMsgOffer, Payload: "{sdp:1}", TS: 1},
{From: SignalRoleBrowser, Type: SignalMsgCandidate, Payload: "{cand:1}", TS: 2},
}
srv := fakeSSEServer(t, want, false)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
var got []SignalMessage
for m := range stream.Events() {
got = append(got, m)
if len(got) == len(want) {
break
}
}
if len(got) != len(want) {
t.Fatalf("got %d messages, want %d", len(got), len(want))
}
for i, m := range got {
if m.From != want[i].From || m.Type != want[i].Type || m.Payload != want[i].Payload {
t.Errorf("[%d] mismatch: %+v want %+v", i, m, want[i])
}
}
}
func TestSignalStreamPropagatesAuthError(t *testing.T) {
srv := fakeSSEServer(t, nil, false)
defer srv.Close()
c := NewClient(srv.URL, "wrong-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.OpenSignalStream(ctx, "session-1")
if err == nil {
t.Fatal("expected auth error, got nil")
}
}
func TestSignalStreamCloseCancelsRead(t *testing.T) {
srv := fakeSSEServer(t, nil, true)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
// Close on a separate goroutine then make sure the events channel drains.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
stream.Close()
}()
for range stream.Events() {
// drain
}
wg.Wait()
}
// TestSignalStreamRejectsOversizedEvent verifies that a hostile or buggy
// server sending an unbounded `data:` event surfaces an error and stops
// the reader instead of growing daemon memory forever.
func TestSignalStreamRejectsOversizedEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
// Send many data: continuation lines until we blow past the
// per-event cap. Each chunk is a short legitimate-looking line.
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
fmt.Fprint(w, "event: signal\n")
for i := 0; i < (sseMaxEventBytes/4096)+8; i++ {
fmt.Fprint(w, chunk)
}
flusher.Flush()
<-r.Context().Done()
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-overflow")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
for range stream.Events() {
// Should never receive a parsed event — the over-sized buffer must
// be rejected before dispatch.
}
if err := stream.Err(); err == nil {
t.Fatal("expected error from oversized event, got nil")
}
}
func TestPostSignalSendsCorrectBody(t *testing.T) {
var bodySeen map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
_ = json.NewDecoder(r.Body).Decode(&bodySeen)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
err := c.PostSignal(context.Background(), "sess-x", SignalMessage{
Type: SignalMsgAnswer,
Payload: "{sdp:answer}",
})
if err != nil {
t.Fatalf("post: %v", err)
}
if bodySeen["from"] != string(SignalRoleAgent) {
t.Errorf("expected from=agent, got %v", bodySeen["from"])
}
if bodySeen["type"] != string(SignalMsgAnswer) {
t.Errorf("expected type=answer, got %v", bodySeen["type"])
}
if bodySeen["payload"] != "{sdp:answer}" {
t.Errorf("expected payload mismatch, got %v", bodySeen["payload"])
}
}

View file

@ -29,7 +29,7 @@ type SyncClient struct {
OnNewTasks func(tasks []Task)
OnControl func(action, taskID string, deleteFiles bool)
OnStreamRequest func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnUpgrade func(version string)
OnScan func()
OnWatchingChange func(watching bool)
@ -199,10 +199,10 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}
}
// WebRTC streaming sessions
for _, ws := range resp.WebRTCSessions {
if sc.OnWebRTCSession != nil {
sc.OnWebRTCSession(ws)
// HLS streaming sessions.
for _, ws := range resp.StreamSessions {
if sc.OnStreamSession != nil {
sc.OnStreamSession(ws)
}
}

View file

@ -374,29 +374,22 @@ type LibraryDeleteRequest struct {
FilePath string `json:"filePath"`
}
// WebRTCSession is a request to open a streaming session for a browser
// player. Transport selects the on-the-wire protocol: empty/"webrtc" runs the
// legacy custom WebRTC DataChannel pipeline; "hls" spawns an HLS session
// (ffmpeg producing fragmented MP4 served over HTTP). The CLI must POST an
// SDP answer to /api/internal/stream/signal/<sessionId> for WebRTC sessions
// and register the HLS session in the StreamServer's HLS registry for HLS
// sessions; either way the source bytes come from FilePath (or, when only
// InfoHash is set, from a download_task on disk).
type WebRTCSession struct {
SessionID string `json:"sessionId"`
// Transport selects the streaming protocol. "" or "webrtc" → legacy
// WebRTC + MSE pipeline (Phase 1). "hls" → HLS over HTTP (Phase 2).
Transport string `json:"transport,omitempty"`
FilePath string `json:"filePath,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TaskID string `json:"taskId,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
// StreamSession is a request to open an HLS streaming session for an
// in-browser player. The CLI registers the HLS session in the StreamServer's
// HLS registry; source bytes come from FilePath (or, when only InfoHash is
// set, from a download_task on disk).
type StreamSession struct {
SessionID string `json:"sessionId"`
FilePath string `json:"filePath,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TaskID string `json:"taskId,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
// Quality target the daemon should aim for when transcoding. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (defer to config).
Quality string `json:"quality,omitempty"`
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
// "use the default/first track" (HLS) or ignored (WebRTC).
// "use the default/first track".
AudioIndex int `json:"audioIndex,omitempty"`
}
@ -405,7 +398,7 @@ type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
WebRTCSessions []WebRTCSession `json:"webrtcSessions,omitempty"`
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`

View file

@ -255,9 +255,6 @@ func runDaemonStart() error {
MaxUploadRate: maxUl,
ListenPort: cfg.Download.ListenPort,
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
VPNTunnel: vpnTunnel,
})
if err != nil {
@ -330,13 +327,7 @@ func runDaemonStart() error {
// Wire: sync receives new tasks → submit to manager or handle stream
d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks {
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" {
if t.Mode == "stream" {
if isStreamingTask(t.ID) {
continue
}
@ -497,23 +488,23 @@ func runDaemonStart() error {
}()
}
// 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) {
// Wire: sync receives HLS streaming session requests. Each session spawns
// one ffmpeg process and registers its HLS playlist with the StreamServer.
// Validate FilePath against allowed dirs to prevent path traversal abuse
// from a compromised server.
d.OnStreamSession = func(sess agent.StreamSession) {
if playerSessionRegistry.has(sess.SessionID) {
return // already running
}
filePath := sess.FilePath
if filePath == "" {
log.Printf("webrtc session %s rejected: empty file path", agent.ShortID(sess.SessionID))
log.Printf("[hls %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",
log.Printf("[hls %s] rejected: path outside allowed dirs: %s",
agent.ShortID(sess.SessionID), filePath)
return
}
@ -521,75 +512,36 @@ func runDaemonStart() error {
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",
log.Printf("[hls %s] rejected: no video file in dir %s",
agent.ShortID(sess.SessionID), filePath)
return
}
filePath = found
}
// 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.
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
}
hlsCtx, hlsCancel := context.WithCancel(ctx)
webrtcRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{
SessionID: sess.SessionID,
SourcePath: filePath,
FileName: sess.FileName,
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
}
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
webrtcRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
streamSrv.HLS().Register(hsess)
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
// 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))
hlsCtx, hlsCancel := context.WithCancel(ctx)
playerSessionRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{
SessionID: sess.SessionID,
SourcePath: filePath,
FileName: sess.FileName,
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
}
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
playerSessionRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
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()
}()
tcRuntime := buildTranscodeRuntime(ctx, cfg)
runCfg := engine.WebRTCStreamConfig{
SessionID: sess.SessionID,
FilePath: filePath,
FileName: sess.FileName,
FileSize: sess.FileSize,
Quality: sess.Quality,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
Signal: agentClient,
Logger: stdLogger{},
Transcode: tcRuntime,
}
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)
}
}
}()
streamSrv.HLS().Register(hsess)
}
// Periodic DHT node persistence (every 5 min)
@ -658,7 +610,7 @@ func runDaemonStart() error {
case sig := <-sigCh:
fmt.Printf("\n Received %s, shutting down...\n", sig)
cancelStreamContexts()
cancelAllWebRTCSessions()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
cancel()
@ -673,7 +625,7 @@ func runDaemonStart() error {
case err := <-errCh:
cancelStreamContexts()
cancelAllWebRTCSessions()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
cancel()
return err

View file

@ -114,9 +114,6 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)

View file

@ -2,7 +2,6 @@ package cmd
import (
"context"
"log"
"sync"
"github.com/torrentclaw/unarr/internal/config"
@ -10,66 +9,57 @@ import (
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// webrtcRegistry tracks per-session cancel funcs for active custom WebRTC
// streams (engine.RunWebRTCStream goroutines). Each session lives only as
// long as its DataChannel; the registry exists so duplicate sync responses
// don't double-spawn the same session and so daemon shutdown can drain.
var webrtcRegistry = &webrtcSessionRegistry{
// playerSessionRegistry tracks per-session cancel funcs for active in-browser
// HLS streaming sessions. Each session lives only as long as its ffmpeg
// process; the registry exists so duplicate sync responses don't double-spawn
// the same session and so daemon shutdown can drain.
var playerSessionRegistry = &playerSessionRegistryT{
cancels: make(map[string]context.CancelFunc),
}
type webrtcSessionRegistry struct {
type playerSessionRegistryT struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func (r *webrtcSessionRegistry) has(sessionID string) bool {
func (r *playerSessionRegistryT) has(sessionID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.cancels[sessionID]
return ok
}
func (r *webrtcSessionRegistry) add(sessionID string, cancel context.CancelFunc) {
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancels[sessionID] = cancel
}
func (r *webrtcSessionRegistry) remove(sessionID string) {
func (r *playerSessionRegistryT) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cancels, sessionID)
}
// cancelAllWebRTCSessions cancels every running session. Called on daemon
// shutdown so pion peers and SSE consumers exit cleanly.
func cancelAllWebRTCSessions() {
webrtcRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(webrtcRegistry.cancels))
for _, c := range webrtcRegistry.cancels {
// cancelAllPlayerSessions cancels every running session. Called on daemon
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
func cancelAllPlayerSessions() {
playerSessionRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(playerSessionRegistry.cancels))
for _, c := range playerSessionRegistry.cancels {
cancels = append(cancels, c)
}
webrtcRegistry.cancels = make(map[string]context.CancelFunc)
webrtcRegistry.mu.Unlock()
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
playerSessionRegistry.mu.Unlock()
for _, c := range cancels {
c()
}
}
// stdLogger is a tiny adapter so engine.RunWebRTCStream can log through the
// standard library logger without pulling in a logging dependency.
type stdLogger struct{}
func (stdLogger) Infof(format string, args ...any) { log.Printf(format, args...) }
func (stdLogger) Warnf(format string, args ...any) { log.Printf("WARN: "+format, args...) }
func (stdLogger) Errorf(format string, args ...any) { log.Printf("ERROR: "+format, args...) }
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// for the WebRTC streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so engine.RunWebRTCStream falls back to
// passthrough — the user gets a clearer codec error from the browser than a
// daemon-side abort.
// for the HLS streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so the caller can short-circuit instead of
// launching a transcoder that will immediately fail.
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
if !cfg.Download.Transcode.Enabled {
return engine.TranscodeRuntime{Disabled: true}

View file

@ -15,7 +15,7 @@ import (
)
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
// would actually use for HLS/WebRTC transcoding. The motivation: a beefy host
// would actually use for HLS transcoding. The motivation: a beefy host
// (e.g. RTX 3090) can still fall back to software encoding when the installed
// ffmpeg binary was built without nvenc/qsv/vaapi support — Homebrew ffmpeg
// is a common offender. Without this command, users see slow / failing 4K

View file

@ -1,65 +0,0 @@
package cmd
import (
"context"
"log"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine"
)
// handleSeedFileTask wraps an arbitrary on-disk file as a single-file
// torrent and adds it to the existing torrent client so the WebRTC
// peer can serve pieces to a browser. Reports the generated info_hash
// back to the server so the web player can target /stream/<hash>.
//
// Runs in its own goroutine; never blocks the claim batch.
func handleSeedFileTask(t agent.Task, dl *engine.TorrentDownloader, client *agent.Client) {
short := agent.ShortID(t.ID)
if t.FilePath == "" {
log.Printf("[%s] seed_file: missing filePath, marking failed", short)
reportSeedFileFailed(client, t.ID, "Missing filePath")
return
}
log.Printf("[%s] seed_file: building torrent from %s", short, t.FilePath)
hash, err := engine.SeedFileOnDownloader(dl, t.FilePath)
if err != nil {
log.Printf("[%s] seed_file: %v", short, err)
reportSeedFileFailed(client, t.ID, err.Error())
return
}
infoHash := hash.HexString()
log.Printf("[%s] seed_file: seeding ih=%s", short, infoHash)
// Push the info_hash + downloading status (file is on disk; from the
// client's perspective it's already complete). The web side polls
// /api/internal/stream/seed-file/<taskId> waiting for this update.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, reportErr := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: t.ID,
Status: "downloading", // semantic: actively serving
InfoHash: infoHash,
FilePath: t.FilePath,
})
if reportErr != nil {
log.Printf("[%s] seed_file: failed to push info_hash: %v", short, reportErr)
}
}
func reportSeedFileFailed(client *agent.Client, taskID, msg string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: taskID,
Status: "failed",
ErrorMessage: msg,
})
if err != nil {
log.Printf("[%s] seed_file: report-failed itself failed: %v", agent.ShortID(taskID), err)
}
}

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.9.3"
var Version = "0.9.4"

View file

@ -51,7 +51,6 @@ type DownloadConfig struct {
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
WebRTC WebRTCConfig `toml:"webrtc"`
Transcode TranscodeConfig `toml:"transcode"`
VPN VPNConfig `toml:"vpn"`
}
@ -84,19 +83,6 @@ type TranscodeConfig struct {
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
}
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
// can fetch pieces via WebRTC data channels — required by the in-browser
// player on torrentclaw.com. Disabled by default; enabling implies upload
// is allowed for active torrents (browsers can't download otherwise).
type WebRTCConfig struct {
Enabled bool `toml:"enabled"` // master switch
Trackers []string `toml:"trackers"` // wss:// signaling trackers
STUNServers []string `toml:"stun_servers"` // stun:host:port
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
TURNPass string `toml:"turn_pass"` // optional
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
@ -121,7 +107,7 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder)
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
@ -146,11 +132,6 @@ func Default() Config {
PreferredMethod: "auto",
MaxConcurrent: 3,
StreamPort: 11818,
WebRTC: WebRTCConfig{
Enabled: true,
Trackers: []string{"wss://tracker.torrentclaw.com"},
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
},
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
@ -231,19 +212,6 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.General.Country = "US"
}
if !meta.IsDefined("downloads", "webrtc", "enabled") {
cfg.Download.WebRTC.Enabled = true
}
if !meta.IsDefined("downloads", "webrtc", "trackers") {
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
}
if !meta.IsDefined("downloads", "webrtc", "stun_servers") {
cfg.Download.WebRTC.STUNServers = []string{
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
}
}
if !meta.IsDefined("downloads", "transcode", "enabled") {
cfg.Download.Transcode.Enabled = true
}

View file

@ -208,17 +208,6 @@ name = "Test"
t.Fatalf("Load failed: %v", err)
}
// WebRTC should be on by default for fresh installs.
if !cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled should default to true when [downloads.webrtc] is absent")
}
if len(cfg.Download.WebRTC.Trackers) == 0 {
t.Error("WebRTC.Trackers should default to torrentclaw tracker when absent")
}
if len(cfg.Download.WebRTC.STUNServers) == 0 {
t.Error("WebRTC.STUNServers should default to public STUN list when absent")
}
// Transcode should be on by default.
if !cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled should default to true when [downloads.transcode] is absent")
@ -238,12 +227,9 @@ func TestLoadRespectsExplicitlyDisabledStreaming(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// User explicitly opted out of webrtc + transcode. Defaults must NOT
// override them — that would silently re-enable features the user disabled.
os.WriteFile(path, []byte(`[downloads.webrtc]
enabled = false
[downloads.transcode]
// User explicitly opted out of transcode. Defaults must NOT override
// it — that would silently re-enable a feature the user disabled.
os.WriteFile(path, []byte(`[downloads.transcode]
enabled = false
`), 0o644)
@ -252,9 +238,6 @@ enabled = false
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled = true, want false (user explicitly disabled)")
}
if cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled = true, want false (user explicitly disabled)")
}

View file

@ -3,9 +3,7 @@
// Browser ↔ daemon over plain HTTP (LAN / Tailscale / UPnP). The daemon runs
// ffmpeg in `-f hls` mode, writing fragmented MP4 segments to a per-session
// tmpdir. Master + media playlists are pre-rendered from the probed source
// duration so the player knows the full timeline before any segment exists,
// which fixes the seek/duration/pause/multi-track problems we hit with the
// raw fMP4-over-WebRTC pipeline.
// duration so the player knows the full timeline before any segment exists.
//
// One HLSSession == one browser playback. Sessions are registered in a
// process-wide map keyed by session ID; the StreamServer routes

View file

@ -9,7 +9,7 @@ import (
)
// StreamProbe summarises the codec / container shape of a file as it relates
// to the WebRTC streaming pipeline. It tells the transcoder whether bytes can
// to the HLS streaming pipeline. It tells the transcoder whether bytes can
// be streamed as-is, just remuxed to fragmented MP4, or fully transcoded.
type StreamProbe struct {
// VideoCodec lowercased — e.g. "h264", "hevc", "av1", "vp9", "mpeg4".

View file

@ -1,138 +0,0 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// SeedFile builds a single-file torrent from an arbitrary on-disk file
// and adds it to an existing torrent client so the WebRTC peer wire
// (already configured on the client) can serve the file to a browser
// that knows the resulting info-hash.
//
// Returns the generated info-hash. The torrent is left attached to the
// client — caller is responsible for keeping it alive while a browser
// is watching. Drop it via Client.RemoveTorrent / Torrent.Drop when
// idle to free resources.
//
// Behaviour notes:
// - The file must already exist; no download is attempted.
// - Piece length follows the libtorrent ladder (16 KiB → 4 MiB).
// - The torrent is "complete" from the agent's POV — it has every
// piece — so the upload-only flow kicks in immediately.
// - WebRTC peer behaviour comes from the client config the caller
// constructed; SeedFile does not toggle DisableWebtorrent itself.
// If the operator's [downloads.webrtc].enabled = false, the file
// is still added but no browser will discover it via WSS tracker.
func SeedFile(client *torrent.Client, filePath string, trackerURLs []string) (metainfo.Hash, error) {
if client == nil {
return metainfo.Hash{}, errors.New("seed_file: torrent client is nil")
}
if filePath == "" {
return metainfo.Hash{}, errors.New("seed_file: filePath is empty")
}
abs, err := filepath.Abs(filePath)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: resolve path: %w", err)
}
st, err := os.Stat(abs)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: stat: %w", err)
}
if st.IsDir() {
return metainfo.Hash{}, fmt.Errorf("seed_file: only single files are supported, %s is a directory", abs)
}
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: build info: %w", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: marshal info: %w", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: makeAnnounceList(trackerURLs),
CreatedBy: "unarr-seed-file",
CreationDate: time.Now().Unix(),
}
ih := mi.HashInfoBytes()
t, err := client.AddTorrent(mi)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: add torrent: %w", err)
}
// Mark every piece as needed so the client treats us as a complete
// seeder right away — anacrolix's verifier will hash the file
// asynchronously and flip pieces to "have" as it goes.
t.DownloadAll()
return ih, nil
}
// makeAnnounceList shapes the tracker URL slice into the bencoded
// AnnounceList format anacrolix expects.
func makeAnnounceList(urls []string) metainfo.AnnounceList {
if len(urls) == 0 {
return nil
}
tier := make([]string, 0, len(urls))
for _, u := range urls {
if u == "" {
continue
}
tier = append(tier, u)
}
if len(tier) == 0 {
return nil
}
return metainfo.AnnounceList{tier}
}
// chooseSeedPieceLength picks the piece size for a single-file torrent
// based on the libtorrent / qBittorrent ladder. Mirrored from the
// wstracker-probe seeder so generated torrents are interoperable.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024:
return 16 * 1024
case size < 64*1024*1024:
return 64 * 1024
case size < 512*1024*1024:
return 256 * 1024
case size < 4*1024*1024*1024:
return 1024 * 1024
default:
return 4 * 1024 * 1024
}
}
// SeedFileOnDownloader is a convenience wrapper that pulls the
// underlying anacrolix client out of a TorrentDownloader and forwards
// to SeedFile. trackerURLs default to the downloader's WebRTC
// trackers when nil/empty.
func SeedFileOnDownloader(d *TorrentDownloader, filePath string) (metainfo.Hash, error) {
if d == nil {
return metainfo.Hash{}, errors.New("seed_file: downloader is nil")
}
trackers := d.cfg.WebRTCTrackers
if !d.cfg.WebRTCEnabled {
// We could still build the torrent, but no browser would find
// it via the WSS tracker — bail loud so the operator notices.
return metainfo.Hash{}, errors.New("seed_file: WebRTC peer disabled in config; set [downloads.webrtc].enabled = true to use this feature")
}
return SeedFile(d.client, filePath, trackers)
}

View file

@ -1,164 +0,0 @@
package engine
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestSeedFile_RejectsMissingFile — explicit error rather than crashing
// inside anacrolix when the path doesn't exist.
func TestSeedFile_RejectsMissingFile(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if _, err := SeedFile(dl.client, "/nonexistent/path", nil); err == nil {
t.Fatal("expected error for missing file")
}
}
// TestSeedFile_RejectsDirectory — single-file torrents only for now.
func TestSeedFile_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
subDir := filepath.Join(dir, "sub")
if err := os.Mkdir(subDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := SeedFile(dl.client, subDir, nil); err == nil {
t.Fatal("expected error for directory path")
}
}
// TestSeedFile_BuildsDeterministicInfoHash — the same file should yield
// the same info_hash on every call so the web client can poll for it.
func TestSeedFile_BuildsDeterministicInfoHash(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.bin")
payload := []byte("hello world — torrentclaw seed_file test")
if err := os.WriteFile(file, payload, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mkClient := func() *TorrentDownloader {
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: t.TempDir(),
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
return dl
}
dl1 := mkClient()
defer dl1.Shutdown(context.Background())
hash1, err := SeedFile(dl1.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("first SeedFile: %v", err)
}
dl2 := mkClient()
defer dl2.Shutdown(context.Background())
hash2, err := SeedFile(dl2.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("second SeedFile: %v", err)
}
if hash1 != hash2 {
t.Fatalf("info_hash not deterministic: %s vs %s", hash1.HexString(), hash2.HexString())
}
if hash1.HexString() == "" || len(hash1.HexString()) != 40 {
t.Fatalf("info_hash is not 40 hex chars: %q", hash1.HexString())
}
}
// TestSeedFileOnDownloader_RequiresWebRTC — silent failure mode is the
// worst UX; bail loud when the operator hasn't opted into WebRTC.
func TestSeedFileOnDownloader_RequiresWebRTC(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: false,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
file := filepath.Join(dir, "data.bin")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if _, err := SeedFileOnDownloader(dl, file); err == nil {
t.Fatal("expected error when WebRTC disabled")
}
}
// TestChooseSeedPieceLength_LadderShape — sanity-check the breakpoints
// stay aligned with the libtorrent reference (16 KiB → 4 MiB).
func TestChooseSeedPieceLength_LadderShape(t *testing.T) {
cases := []struct {
size int64
expect int64
}{
{1, 16 * 1024},
{4 * 1024 * 1024, 64 * 1024},
{64 * 1024 * 1024, 256 * 1024},
{512 * 1024 * 1024, 1024 * 1024},
{4 * 1024 * 1024 * 1024, 4 * 1024 * 1024},
}
for _, c := range cases {
if got := chooseSeedPieceLength(c.size); got != c.expect {
t.Errorf("chooseSeedPieceLength(%d) = %d want %d", c.size, got, c.expect)
}
}
}
// TestMakeAnnounceList_HandlesEmpty — nil/empty in → nil out, so
// AddTorrent doesn't see a dangling tier with no URLs.
func TestMakeAnnounceList_HandlesEmpty(t *testing.T) {
if got := makeAnnounceList(nil); got != nil {
t.Errorf("nil input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{}); got != nil {
t.Errorf("empty input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{"", " ", ""}); got != nil {
// Empty strings should be filtered; if everything is empty,
// nil is the right answer.
// (We do NOT trim whitespace today — only literal "".)
if len(got) != 1 || len(got[0]) != 1 {
t.Errorf("expected 1 single-element tier, got %+v", got)
}
}
got := makeAnnounceList([]string{"wss://a", "", "wss://b"})
if len(got) != 1 || len(got[0]) != 2 {
t.Fatalf("expected 1 tier of 2 URLs, got %+v", got)
}
}

View file

@ -12,7 +12,7 @@ import (
"time"
)
// streamSource abstracts the byte source served over the WebRTC DataChannel.
// streamSource abstracts the byte source consumed by the HLS transcoder.
// Two implementations:
// - diskFileSource — direct passthrough of the on-disk file.
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in

View file

@ -16,7 +16,6 @@ import (
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term"
@ -73,14 +72,6 @@ type TorrentConfig struct {
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0)
// WebRTC peer (WebTorrent protocol) for browser ↔ unarr P2P streaming.
// When enabled, anacrolix/torrent's built-in webtorrent package handles
// the WSS signaling + WebRTC data channels. Implies upload allowed for
// every torrent in the client (browsers can't pull pieces otherwise).
WebRTCEnabled bool
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
// add-on). nil = downloads in the clear. Brought up by the daemon.
@ -111,26 +102,11 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
// WebRTC peers (browsers) can only pull pieces from us if upload is
// enabled. We honour SeedEnabled for the long-tail seed-after-complete
// behaviour but unconditionally allow upload while WebRTC is on so an
// active download can still serve to a watching browser.
tcfg.NoUpload = !cfg.SeedEnabled && !cfg.WebRTCEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning) // bumped from Critical for WebRTC peer + tracker announce visibility
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
// WebRTC / WebTorrent peer: anacrolix auto-routes ws://+wss:// trackers
// to the bundled webtorrent.TrackerClient. We only need to populate the
// ICE server list so the SDP offers we send carry usable candidates.
if cfg.WebRTCEnabled {
tcfg.DisableWebtorrent = false
if len(cfg.ICEServers) > 0 {
tcfg.ICEServerList = cfg.ICEServers
}
log.Printf("[torrent] WebRTC peer enabled (trackers=%d ice_servers=%d)",
len(cfg.WebRTCTrackers), len(cfg.ICEServers))
} else {
tcfg.DisableWebtorrent = true
}
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true
// --- Performance optimizations ---
@ -657,30 +633,17 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
return totalBytes, fileName
}
// buildMagnet composes a magnet URI for the info hash. extraTrackers (e.g.
// wss://… for WebRTC peer signaling) are prepended so anacrolix's
// webtorrent.TrackerClient picks them up first; the static UDP list
// follows. Empty / whitespace entries in extraTrackers are skipped.
func buildMagnet(infoHash string, extraTrackers ...string) string {
// buildMagnet composes a magnet URI for the info hash with the static
// tracker list.
func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash}
for _, t := range extraTrackers {
t = strings.TrimSpace(t)
if t == "" {
continue
}
params = append(params, "tr="+url.QueryEscape(t))
}
for _, tracker := range defaultTrackers {
params = append(params, "tr="+url.QueryEscape(tracker))
}
return "magnet:?" + strings.Join(params, "&")
}
// buildMagnet on the downloader injects its WebRTC trackers when enabled.
func (d *TorrentDownloader) buildMagnet(infoHash string) string {
if d != nil && d.cfg.WebRTCEnabled {
return buildMagnet(infoHash, d.cfg.WebRTCTrackers...)
}
return buildMagnet(infoHash)
}

View file

@ -0,0 +1,64 @@
package engine
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and
// makes libx264 refuse with "VBV bitrate > level limit". This helper picks
// the bitrate that matches the level libx264 will actually accept.
func capForHeight(height int) qualityCap {
switch {
case height <= 0:
return qualityCap{}
case height <= 480:
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
case height <= 720:
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case height <= 1080:
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case height <= 1440:
return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"}
default:
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
}
}

View file

@ -11,10 +11,9 @@ import (
"time"
)
// TranscodeOpts steers how Transcoder builds its ffmpeg command line. Defaults
// match the project's plan/clever-weaving-dove.md (Fase 2.5):
// TranscodeOpts steers how Transcoder builds its ffmpeg command line.
//
// - Output: fragmented MP4 readable by browser <video> via MSE-less Range.
// - Output: fragmented MP4 chunked into HLS segments by the muxer.
// - Audio: AAC stereo @ 192kbps unless source already AAC (then -c:a copy).
// - Video: copy when h264 8-bit; otherwise transcode to h264 with HW encode
// when available, software fallback at "veryfast" preset.
@ -31,11 +30,11 @@ type TranscodeOpts struct {
}
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
// fragmented MP4 bytes for the WebRTC pump to forward to the browser.
// fragmented MP4 bytes; the HLS muxer slices them into segments served over HTTP.
//
// One Transcoder == one playback position. A seek beyond the buffered window
// requires Close()ing this transcoder and starting a new one with a higher
// StartSeconds (handled in webrtc_stream.go).
// StartSeconds (handled by the HLS session at ffmpeg start time).
//
// A single internal goroutine owns cmd.Wait() — never call cmd.Wait()
// directly from outside (os/exec forbids concurrent Wait callers). Use
@ -269,12 +268,9 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
filterChain = "format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
// Force AAC-LC stereo 48 kHz so MSE's CHUNK_DEMUXER accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape that MSE refuses to
// parse (the <video src=blob:> demuxer is more forgiving), so we
// always downmix to stereo and resample to 48 kHz here. Source
// material that's already stereo passes through losslessly aside
// from the re-encode.
// Force AAC-LC stereo 48 kHz so the hls.js demuxer accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape the demuxer refuses
// to parse, so always downmix to stereo + resample to 48 kHz here.
args = append(args,
"-c:a", "aac",
"-b:a", coalesce(opts.AudioBitrate, "192k"),
@ -285,13 +281,12 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
// Common output flags — fragmented MP4 to a single pipe.
//
// * empty_moov + default_base_moof: write a header-only init segment
// up front so MSE can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media. Without
// this, ffmpeg only splits at keyframes, which on a high-bitrate
// 1080p stream produces 8 MiB+ mdat boxes — MSE refuses to parse
// the first fragment until the whole mdat lands, so playback never
// starts.
// * empty_moov + default_base_moof: header-only init segment up front
// so the demuxer can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media.
// Without it ffmpeg only splits at keyframes; a high-bitrate 1080p
// stream produces 8 MiB+ mdat boxes that delay the first fragment
// until the whole mdat lands and playback never starts.
// * negative_cts_offsets: lets b-frames carry the right pts/dts so
// decoders don't reset the playhead to 0 every fragment.
args = append(args,

View file

@ -1,36 +0,0 @@
package engine
import (
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
// BuildICEServers converts a config.WebRTCConfig into the
// []webrtc.ICEServer slice that anacrolix/torrent's webtorrent client
// needs. STUN entries become bare URLs; TURN entries inherit the shared
// TURNUser / TURNPass credentials. Returns nil when WebRTC is disabled.
func BuildICEServers(cfg config.WebRTCConfig) []webrtc.ICEServer {
if !cfg.Enabled {
return nil
}
var servers []webrtc.ICEServer
for _, s := range cfg.STUNServers {
if s == "" {
continue
}
servers = append(servers, webrtc.ICEServer{URLs: []string{s}})
}
for _, t := range cfg.TURNServers {
if t == "" {
continue
}
entry := webrtc.ICEServer{URLs: []string{t}}
if cfg.TURNUser != "" {
entry.Username = cfg.TURNUser
entry.Credential = cfg.TURNPass
entry.CredentialType = webrtc.ICECredentialTypePassword
}
servers = append(servers, entry)
}
return servers
}

View file

@ -1,807 +0,0 @@
// Package engine — webrtc_stream.go implements the daemon side of the custom
// WebRTC byte-streaming protocol. The browser opens an RTCDataChannel via
// SDP exchange (signalled over the web's HTTP + SSE relay); this code:
//
// 1. Parses the browser's SDP offer.
// 2. Creates a pion PeerConnection bound to the configured ICE servers.
// 3. Answers + trickles its own ICE candidates back through the signal client.
// 4. On DataChannel open, sends a HELLO frame describing the file.
// 5. Services RangeReq frames by reading from disk and emitting RangeData
// chunks (16 KiB each) followed by a RangeEnd.
// 6. Honours app-level backpressure via SetBufferedAmountLowThreshold +
// OnBufferedAmountLow — Chromium closes a DataChannel when bufferedAmount
// exceeds 16 MiB, so we MUST pause the writer.
//
// No anacrolix, no torrent metadata. Just a peer-to-peer file server over
// WebRTC. Pass-through path; transcoding lives in transcoder.go (Fase 2.5).
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine/wire"
)
// Tunables — values match the protocol spec in plan/clever-weaving-dove.md.
const (
// dcChunkPayload is the per-frame application payload size. Must match
// wire.MaxChunkPayload so RangeData frames fit one SCTP message.
dcChunkPayload = wire.MaxChunkPayload
// dcHighWatermark is the bufferedAmount cap above which the writer pauses.
// Chromium closes DCs above 16 MiB; pause well below.
dcHighWatermark = 8 << 20
// dcLowWatermark triggers OnBufferedAmountLow → resume the writer.
dcLowWatermark = 1 << 20
// rangeReqConcurrency is the cap on in-flight range responses per session.
rangeReqConcurrency = 4
// helloDeadline is the max wait for the DataChannel to open after answer.
helloDeadline = 30 * time.Second
)
// WebRTCStreamConfig describes a single browser ↔ daemon stream session.
type WebRTCStreamConfig struct {
SessionID string
FilePath string
FileName string
FileSize int64
ICEServers []webrtc.ICEServer
Signal *agent.Client
// Logger receives diagnostic events; a nil logger swallows everything.
Logger StreamLogger
// Transcode steers on-the-fly transcoding when source codecs are not
// browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it.
Transcode TranscodeRuntime
// Quality overrides the cap from Transcode for this session. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (= defer to
// Transcode defaults).
Quality string
}
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// StreamLogger is an injectable logger so tests can capture events.
type StreamLogger interface {
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
type nopLogger struct{}
func (nopLogger) Infof(string, ...any) {}
func (nopLogger) Warnf(string, ...any) {}
func (nopLogger) Errorf(string, ...any) {}
func logger(l StreamLogger) StreamLogger {
if l == nil {
return nopLogger{}
}
return l
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and
// makes libx264 refuse with "VBV bitrate > level limit". This helper picks
// the bitrate that matches the level libx264 will actually accept.
func capForHeight(height int) qualityCap {
switch {
case height <= 0:
return qualityCap{}
case height <= 480:
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
case height <= 720:
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case height <= 1080:
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case height <= 1440:
return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"}
default:
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
}
}
// buildStreamSource picks between passthrough and transcoded source. ffprobe
// failure or missing ffmpeg falls back to passthrough — the browser surfaces
// a clearer codec error than us refusing to start.
//
// Quality override (cfg.Quality) can force a downscale even when the source
// codec is browser-friendly: a 4K h264 file watched on a phone with quality
// "720p" must transcode (otherwise we'd ship 4K bytes for a 6" screen).
func buildStreamSource(
ctx context.Context,
abs string,
displayName string,
cfg WebRTCStreamConfig,
log StreamLogger,
) (streamSource, error) {
tc := cfg.Transcode
qcap := resolveQualityCap(cfg.Quality)
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
return newDiskFileSource(abs)
}
probe, err := ProbeFile(ctx, tc.FFprobePath, abs)
if err != nil {
log.Warnf("[wrtc %s] probe failed (%v) — passthrough", agent.ShortID(cfg.SessionID), err)
return newDiskFileSource(abs)
}
action := DecideAction(probe)
// Quality cap can promote a passthrough/remux decision into a full video
// transcode when the source resolution exceeds the requested cap.
if qcap.MaxHeight > 0 && probe.Height > 0 && probe.Height > qcap.MaxHeight && action != ActionTranscodeVideo {
log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode",
agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, qcap.MaxHeight)
action = ActionTranscodeVideo
}
if action == ActionPassthrough {
log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)",
agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container)
return newDiskFileSource(abs)
}
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec,
action, coalesce(cfg.Quality, "default"))
maxHeight := tc.MaxHeight
videoBitrate := tc.VideoBitrate
if qcap.MaxHeight > 0 {
maxHeight = qcap.MaxHeight
videoBitrate = qcap.VideoBitrate
}
opts := TranscodeOpts{
Action: action,
HWAccel: tc.HWAccel,
Preset: tc.Preset,
VideoBitrate: videoBitrate,
AudioBitrate: tc.AudioBitrate,
MaxHeight: maxHeight,
SourceHeight: probe.Height,
FFmpegPath: tc.FFmpegPath,
}
return newTranscodeSource(ctx, abs, probe, action, opts, displayName)
}
// RunWebRTCStream blocks until the session ends — either the DataChannel
// closes, the peer connection drops, or ctx is cancelled. Always returns a
// non-nil error explaining the termination reason.
func RunWebRTCStream(ctx context.Context, cfg WebRTCStreamConfig) error {
log := logger(cfg.Logger)
if cfg.SessionID == "" {
return errors.New("webrtc_stream: empty SessionID")
}
if cfg.FilePath == "" {
return errors.New("webrtc_stream: empty FilePath")
}
abs, err := filepath.Abs(cfg.FilePath)
if err != nil {
return fmt.Errorf("webrtc_stream: resolve path: %w", err)
}
displayName := cfg.FileName
if displayName == "" {
displayName = filepath.Base(abs)
}
// Decide passthrough vs transcoding. Probe is best-effort: if ffprobe
// is missing or fails we fall back to passthrough (the browser will
// surface a clearer error than us guessing wrong).
source, err := buildStreamSource(ctx, abs, displayName, cfg, log)
if err != nil {
return fmt.Errorf("webrtc_stream: build source: %w", err)
}
defer source.Close()
// 1. Build PeerConnection.
api := webrtc.NewAPI()
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: cfg.ICEServers,
})
if err != nil {
return fmt.Errorf("webrtc_stream: new peer connection: %w", err)
}
defer pc.Close()
sessionCtx, cancelSession := context.WithCancel(ctx)
defer cancelSession()
// Stop the session when ICE drops permanently. "Disconnected" is
// transient per RFC 8445 (NAT rebind, brief packet loss) — wait for
// "Failed" or "Closed" before tearing down.
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Infof("[wrtc %s] ice=%s", agent.ShortID(cfg.SessionID), state.String())
switch state {
case webrtc.ICEConnectionStateFailed,
webrtc.ICEConnectionStateClosed:
cancelSession()
case webrtc.ICEConnectionStateUnknown,
webrtc.ICEConnectionStateNew,
webrtc.ICEConnectionStateChecking,
webrtc.ICEConnectionStateConnected,
webrtc.ICEConnectionStateCompleted,
webrtc.ICEConnectionStateDisconnected:
// Disconnected is transient (RFC 8445 — NAT rebind / packet loss);
// the others are normal progress states. Don't tear the session down.
}
})
// Trickle our ICE candidates back to the browser.
// PostSignal runs on its own goroutine so a slow signal server can't
// stall pion's ICE-gathering thread.
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidateEnd,
Payload: "",
})
}()
return
}
init := c.ToJSON()
payload, _ := json.Marshal(init)
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidate,
Payload: string(payload),
})
}()
})
// Browser is the offerer — we react to the DataChannel it creates.
dcReady := make(chan *webrtc.DataChannel, 1)
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
log.Infof("[wrtc %s] data channel '%s' open", agent.ShortID(cfg.SessionID), dc.Label())
select {
case dcReady <- dc:
default:
// Browser opened a second DC — ignore, we only serve one.
log.Warnf("[wrtc %s] extra data channel ignored", agent.ShortID(cfg.SessionID))
}
})
// 2. Drive the SDP exchange. Any error from the loop (browser sent
// "bye", signal stream closed, etc.) cancels the session so we don't
// dangle on the DC waiting for a peer that's already gone.
sdpDone := make(chan error, 1)
go func() {
err := runSDPExchange(sessionCtx, pc, cfg)
sdpDone <- err
if err != nil && sessionCtx.Err() == nil {
log.Infof("[wrtc %s] signal loop ended: %v", agent.ShortID(cfg.SessionID), err)
cancelSession()
}
}()
// 3. Wait for either SDP error or DataChannel open.
var dc *webrtc.DataChannel
select {
case err := <-sdpDone:
if err != nil {
return fmt.Errorf("sdp exchange: %w", err)
}
// SDP complete — wait for the DC.
select {
case dc = <-dcReady:
case <-time.After(helloDeadline):
return errors.New("webrtc_stream: data channel never opened")
case <-sessionCtx.Done():
return sessionCtx.Err()
}
case dc = <-dcReady:
// DC opened before SDP loop reported done (typical: the loop keeps
// running to ferry remote ICE candidates).
case <-sessionCtx.Done():
return sessionCtx.Err()
}
// 4. Wire up the data channel pump.
pump := newDataChannelPump(dc, source, log, cancelSession)
dc.OnOpen(pump.onOpen)
dc.OnMessage(pump.onMessage)
dc.OnClose(func() {
log.Infof("[wrtc %s] data channel closed", agent.ShortID(cfg.SessionID))
cancelSession()
})
<-sessionCtx.Done()
pump.shutdown()
return sessionCtx.Err()
}
// runSDPExchange consumes signal events from the browser and answers the SDP
// offer. Keeps running for the lifetime of sessionCtx so trickle candidates
// flow in both directions. Reopens the SSE stream on every clean close — the
// server caps each response at ~25 s.
func runSDPExchange(ctx context.Context, pc *webrtc.PeerConnection, cfg WebRTCStreamConfig) error {
gotOffer := false
for ctx.Err() == nil {
stream, err := cfg.Signal.OpenSignalStream(ctx, cfg.SessionID)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("open signal stream: %w", err)
}
err = consumeSignalStream(ctx, pc, cfg, stream, &gotOffer)
stream.Close()
if err != nil {
return err
}
}
return ctx.Err()
}
// consumeSignalStream drains a single SSE connection until it closes or
// produces a hard error. Returns nil on a clean server-side disconnect so the
// caller can reopen.
func consumeSignalStream(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
stream *agent.SignalEventStream,
gotOffer *bool,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-stream.Events():
if !ok {
if err := stream.Err(); err != nil {
return fmt.Errorf("signal stream: %w", err)
}
return nil
}
if err := handleSignal(ctx, pc, cfg, msg, gotOffer); err != nil {
return err
}
}
}
}
func handleSignal(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
msg agent.SignalMessage,
gotOffer *bool,
) error {
switch msg.Type {
case agent.SignalMsgAnswer:
// Browser is the offerer in our protocol — we never expect an answer
// from the other side. Drop silently (also satisfies exhaustive lint).
return nil
case agent.SignalMsgOffer:
if *gotOffer {
return nil // ignore duplicates
}
var offer webrtc.SessionDescription
if err := json.Unmarshal([]byte(msg.Payload), &offer); err != nil {
return fmt.Errorf("decode offer: %w", err)
}
if err := pc.SetRemoteDescription(offer); err != nil {
return fmt.Errorf("set remote description: %w", err)
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("create answer: %w", err)
}
if err := pc.SetLocalDescription(answer); err != nil {
return fmt.Errorf("set local description: %w", err)
}
// Send back the local description *with* gathered candidates so far —
// remaining candidates trickle separately via OnICECandidate.
ld := pc.LocalDescription()
payload, _ := json.Marshal(ld)
if err := cfg.Signal.PostSignal(ctx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgAnswer,
Payload: string(payload),
}); err != nil {
return fmt.Errorf("post answer: %w", err)
}
*gotOffer = true
case agent.SignalMsgCandidate:
if !*gotOffer {
// Browser may trickle candidates before we've seen the offer in
// rare race conditions — drop. Browser will retransmit.
return nil
}
var init webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(msg.Payload), &init); err != nil {
return fmt.Errorf("decode candidate: %w", err)
}
if err := pc.AddICECandidate(init); err != nil {
return fmt.Errorf("add ice candidate: %w", err)
}
case agent.SignalMsgCandidateEnd:
// No-op — pion gathers complete on its own.
case agent.SignalMsgBye:
return errors.New("browser sent bye")
}
return nil
}
// dataChannelPump owns the DC + stream source and serves wire-protocol frames.
type dataChannelPump struct {
dc *webrtc.DataChannel
source streamSource
log StreamLogger
cancel context.CancelFunc
// Flow control: writers wait on resumeCh when bufferedAmount goes high.
paused atomic.Bool
resumeCh chan struct{}
// Active range responses keyed by stream_id so CANCEL frames can stop them.
activeMu sync.Mutex
active map[uint32]context.CancelFunc
// Bound concurrent in-flight responses.
sem chan struct{}
// closed once shutdown() has been called.
closed atomic.Bool
}
func newDataChannelPump(
dc *webrtc.DataChannel,
source streamSource,
log StreamLogger,
cancel context.CancelFunc,
) *dataChannelPump {
p := &dataChannelPump{
dc: dc,
source: source,
log: log,
cancel: cancel,
resumeCh: make(chan struct{}, 1),
active: make(map[uint32]context.CancelFunc),
sem: make(chan struct{}, rangeReqConcurrency),
}
dc.SetBufferedAmountLowThreshold(dcLowWatermark)
dc.OnBufferedAmountLow(p.onBufferedAmountLow)
return p
}
func (p *dataChannelPump) onOpen() {
// Use estimated size for transcoded streams so the browser scrubber has
// something to anchor on. Real size is reflected by Range responses as
// ffmpeg writes more bytes; the estimate just bootstraps the UI.
announceSize := p.source.EstimatedSize()
transcoding := p.source.Transcoded()
// Browsers refuse to start playback when Content-Length is 0. If we don't
// have a duration estimate (e.g. ffprobe couldn't tag the source), declare
// a large sentinel so the browser issues range requests; the Transcoding
// flag tells it the value is provisional.
if transcoding && announceSize <= 0 {
announceSize = math.MaxInt64
}
// Seekable=true even for transcoded sources because we read from a tmp
// file (random access). Seek backwards just works; seek forward beyond
// what ffmpeg has produced will block briefly inside ReadAt.
seekable := true
hello := wire.HelloPayload{
FileSize: uint64(announceSize),
Transcoding: transcoding,
Seekable: seekable,
FileName: p.source.FileName(),
}
payload := wire.EncodeHello(hello)
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameHello,
Flags: wire.HelloFlags(transcoding, seekable),
StreamID: 0,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Errorf("send hello: %v", err)
p.cancel()
}
}
func (p *dataChannelPump) onMessage(msg webrtc.DataChannelMessage) {
if len(msg.Data) < wire.HeaderSize {
p.log.Warnf("dc: short frame %d bytes", len(msg.Data))
return
}
hdr, err := wire.DecodeHeader(msg.Data[:wire.HeaderSize])
if err != nil {
p.log.Warnf("dc: bad header: %v", err)
return
}
payload := msg.Data[wire.HeaderSize:]
if uint32(len(payload)) != hdr.Length {
p.log.Warnf("dc: payload length mismatch: hdr=%d got=%d", hdr.Length, len(payload))
return
}
switch hdr.Type {
case wire.FrameRangeReq:
req, err := wire.DecodeRangeReq(payload)
if err != nil {
p.log.Warnf("dc: bad range_req: %v", err)
return
}
go p.serveRange(hdr.StreamID, req)
case wire.FrameCancel:
p.cancelStream(hdr.StreamID)
case wire.FramePing:
p.sendSimpleFrame(wire.FramePong, hdr.StreamID, nil)
case wire.FramePong:
// no-op
default:
p.log.Warnf("dc: unknown frame type 0x%02x", hdr.Type)
}
}
func (p *dataChannelPump) cancelStream(streamID uint32) {
p.activeMu.Lock()
cancel, ok := p.active[streamID]
delete(p.active, streamID)
p.activeMu.Unlock()
if ok {
cancel()
}
}
func (p *dataChannelPump) sendSimpleFrame(t wire.FrameType, streamID uint32, payload []byte) {
frame := wire.EncodeFrame(wire.Header{
Type: t,
StreamID: streamID,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Warnf("dc: send type=0x%02x: %v", t, err)
}
}
func (p *dataChannelPump) serveRange(streamID uint32, req wire.RangeReqPayload) {
if p.closed.Load() {
return
}
// Bound concurrency.
select {
case p.sem <- struct{}{}:
case <-time.After(5 * time.Second):
p.log.Warnf("dc: range_req sid=%d dropped (concurrency cap)", streamID)
p.sendRangeEnd(streamID, 1)
return
}
defer func() { <-p.sem }()
// Reject offsets above MaxInt64 — uint64→int64 narrowing would wrap to a
// negative value and bypass the bounds check, then ReadAt would be called
// with a negative offset.
currentSize := p.source.Size()
finalSize := p.source.EstimatedSize()
if req.Offset > math.MaxInt64 {
p.sendRangeEnd(streamID, 2) // out of range
return
}
// For transcoded streams `currentSize` grows over time; only reject when
// the offset is past the *estimated* final size.
if int64(req.Offset) >= finalSize && p.source.Final() {
p.sendRangeEnd(streamID, 2)
return
}
want := int64(req.Length)
if req.Length > math.MaxInt64 {
want = 0 // treat absurd length as "remainder of file"
}
// Cap by *final* size, not currentSize. For a still-transcoding stream
// currentSize grows over time and ReadAt below already blocks until
// ffmpeg produces the requested bytes (with a deadline). If we cap
// `want` by currentSize here we'll send an empty RangeEnd whenever the
// browser asks for bytes faster than ffmpeg writes them — which is
// always true on the first few seconds — and the browser then aborts
// playback with "Format error".
cap := finalSize
if !p.source.Final() && cap < int64(req.Offset)+1 {
// Estimate too small: serve as much as the browser asked for and
// let ReadAt block.
cap = int64(req.Offset) + want
}
if int64(req.Offset) >= cap && p.source.Final() {
// Past true end of a finished file.
p.sendRangeEnd(streamID, 0)
return
}
remaining := cap - int64(req.Offset)
if remaining < 0 {
remaining = 0
}
if want <= 0 || want > remaining {
want = remaining
}
p.log.Infof("dc: range_req sid=%d offset=%d wantReq=%d wantServe=%d currentSize=%d final=%v",
streamID, req.Offset, req.Length, want, currentSize, p.source.Final())
if want <= 0 {
// Only happens for a finished file when offset is at/past EOF.
p.sendRangeEnd(streamID, 0)
return
}
ctx, cancel := context.WithCancel(context.Background())
p.activeMu.Lock()
if p.active == nil {
p.activeMu.Unlock()
cancel()
p.sendRangeEnd(streamID, 3)
return
}
p.active[streamID] = cancel
p.activeMu.Unlock()
defer func() {
p.activeMu.Lock()
delete(p.active, streamID)
p.activeMu.Unlock()
cancel()
}()
buf := make([]byte, dcChunkPayload)
offset := int64(req.Offset)
end := offset + want
for offset < end {
if ctx.Err() != nil || p.closed.Load() {
return
}
// Wait if the DC is buffering too much.
if err := p.waitForLowWater(ctx); err != nil {
return
}
chunkLen := int64(len(buf))
if end-offset < chunkLen {
chunkLen = end - offset
}
n, rerr := p.source.ReadAt(buf[:chunkLen], offset)
if n > 0 {
// EOF on a short read means this is the final chunk — flag it so the
// browser doesn't wait for more data before processing RangeEnd.
isLast := offset+int64(n) >= end || rerr == io.EOF
if err := p.sendRangeData(streamID, buf[:n], isLast); err != nil {
p.log.Warnf("dc: send range_data sid=%d: %v", streamID, err)
return
}
offset += int64(n)
}
if rerr != nil {
if rerr == io.EOF {
break
}
p.log.Errorf("dc: read sid=%d: %v", streamID, rerr)
p.sendRangeEnd(streamID, 3)
return
}
}
p.sendRangeEnd(streamID, 0)
}
func (p *dataChannelPump) sendRangeData(streamID uint32, data []byte, last bool) error {
var flags uint8
if last {
flags |= wire.FlagLastChunk
}
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameRangeData,
Flags: flags,
StreamID: streamID,
Length: uint32(len(data)),
}, data)
return p.dc.Send(frame)
}
func (p *dataChannelPump) sendRangeEnd(streamID uint32, status uint32) {
payload := wire.EncodeRangeEnd(wire.RangeEndPayload{Status: status})
p.sendSimpleFrame(wire.FrameRangeEnd, streamID, payload)
}
func (p *dataChannelPump) waitForLowWater(ctx context.Context) error {
if p.dc.BufferedAmount() < dcHighWatermark {
return nil
}
p.paused.Store(true)
for {
// Drain any stale resume signal first.
select {
case <-p.resumeCh:
default:
}
if p.dc.BufferedAmount() < dcHighWatermark {
p.paused.Store(false)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-p.resumeCh:
case <-time.After(500 * time.Millisecond):
// Belt-and-braces poll in case OnBufferedAmountLow misses a fire.
}
}
}
func (p *dataChannelPump) onBufferedAmountLow() {
if !p.paused.Load() {
return
}
select {
case p.resumeCh <- struct{}{}:
default:
}
}
func (p *dataChannelPump) shutdown() {
if !p.closed.CompareAndSwap(false, true) {
return
}
p.activeMu.Lock()
for _, cancel := range p.active {
cancel()
}
p.active = nil
p.activeMu.Unlock()
}

View file

@ -1,177 +0,0 @@
package engine
import (
"context"
"net/url"
"strings"
"testing"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
const validHash = "aaf2c71b0e0a03d3f9b2a3e1d5c6b7a8f0e1d2c3"
// TestBuildMagnet_NoExtras verifies the legacy free-function path keeps
// emitting only the static defaultTrackers list.
func TestBuildMagnet_NoExtras(t *testing.T) {
got := buildMagnet(validHash)
if !strings.HasPrefix(got, "magnet:?xt=urn:btih:"+validHash) {
t.Fatalf("magnet missing xt: %s", got)
}
if !strings.Contains(got, url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")) {
t.Fatal("expected default UDP tracker absent")
}
if strings.Contains(got, "wss%3A") {
t.Fatalf("unexpected WSS tracker leaked when none requested: %s", got)
}
}
// TestBuildMagnet_WithExtraTrackers verifies extraTrackers (e.g. WebRTC
// WSS endpoints) are prepended before the defaults and properly URL-encoded.
func TestBuildMagnet_WithExtraTrackers(t *testing.T) {
got := buildMagnet(validHash, "wss://tracker.torrentclaw.com")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
encUDP := url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("WSS tracker missing: %s", got)
}
wssIdx := strings.Index(got, encWss)
udpIdx := strings.Index(got, encUDP)
if wssIdx < 0 || udpIdx < 0 || wssIdx > udpIdx {
t.Fatalf("WSS tracker should appear BEFORE UDP defaults: wss=%d udp=%d", wssIdx, udpIdx)
}
}
// TestBuildMagnet_TrimsAndSkipsEmpty makes sure callers passing config-derived
// slices with stray whitespace or empty strings don't get malformed magnets.
func TestBuildMagnet_TrimsAndSkipsEmpty(t *testing.T) {
got := buildMagnet(validHash, " wss://tracker.torrentclaw.com ", "", " ")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("trimmed WSS tracker missing: %s", got)
}
if strings.Contains(got, "tr=&") || strings.HasSuffix(got, "tr=") {
t.Fatalf("empty tracker emitted: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCDisabled confirms the downloader
// method does NOT inject WebRTCTrackers when WebRTCEnabled is false.
func TestTorrentDownloader_buildMagnet_WebRTCDisabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: false,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
}}
got := d.buildMagnet(validHash)
if strings.Contains(got, "wss%3A") {
t.Fatalf("WSS tracker leaked while WebRTCEnabled=false: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCEnabled confirms the WSS trackers
// are present when WebRTCEnabled is true.
func TestTorrentDownloader_buildMagnet_WebRTCEnabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com", "wss://tracker2.example.com"},
}}
got := d.buildMagnet(validHash)
for _, want := range []string{
"wss://tracker.torrentclaw.com",
"wss://tracker2.example.com",
} {
if !strings.Contains(got, url.QueryEscape(want)) {
t.Fatalf("expected tracker %q missing in magnet: %s", want, got)
}
}
}
// TestBuildICEServers_DisabledReturnsNil ensures we don't leak STUN/TURN
// configuration into the torrent client when the user has WebRTC off.
func TestBuildICEServers_DisabledReturnsNil(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: false,
STUNServers: []string{"stun:stun.l.google.com:19302"},
})
if got != nil {
t.Fatalf("expected nil ICE servers when disabled, got %+v", got)
}
}
// TestBuildICEServers_STUNOnly converts STUN entries to bare ICEServer
// records with no credentials.
func TestBuildICEServers_STUNOnly(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302", "", "stun:stun1.l.google.com:19302"},
})
if len(got) != 2 {
t.Fatalf("expected 2 STUN servers (empty skipped), got %d (%+v)", len(got), got)
}
if got[0].URLs[0] != "stun:stun.l.google.com:19302" {
t.Fatalf("first server unexpected: %+v", got[0])
}
if got[0].Username != "" || got[0].Credential != nil {
t.Fatalf("STUN entry should have no credentials, got %+v", got[0])
}
}
// TestNewTorrentDownloader_WebRTCEnabled creates a downloader with the
// WebRTC peer fully wired up and confirms the constructor doesn't error
// (anacrolix accepts the ICE server list, port binds, etc.).
func TestNewTorrentDownloader_WebRTCEnabled(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0, // let the OS pick — avoid clashes in CI
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
ICEServers: BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
}),
})
if err != nil {
t.Fatalf("WebRTC-enabled downloader failed to start: %v", err)
}
defer func() {
if err := dl.Shutdown(context.Background()); err != nil {
t.Logf("shutdown: %v", err)
}
}()
// Magnet for any task should now contain the WSS tracker.
got := dl.buildMagnet(validHash)
if !strings.Contains(got, "wss%3A%2F%2Ftracker.torrentclaw.com") {
t.Fatalf("WebRTC magnet missing WSS tracker: %s", got)
}
}
// TestBuildICEServers_TURNWithCreds applies TURNUser/TURNPass to every TURN
// entry so the operator only specifies them once.
func TestBuildICEServers_TURNWithCreds(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
TURNServers: []string{"turn:turn.example.com:3478"},
TURNUser: "alice",
TURNPass: "s3cr3t",
})
if len(got) != 2 {
t.Fatalf("expected 1 STUN + 1 TURN, got %d", len(got))
}
turn := got[1]
if turn.URLs[0] != "turn:turn.example.com:3478" {
t.Fatalf("TURN URL wrong: %+v", turn)
}
if turn.Username != "alice" {
t.Fatalf("TURN username wrong: %s", turn.Username)
}
if turn.Credential != "s3cr3t" {
t.Fatalf("TURN credential wrong: %v", turn.Credential)
}
if turn.CredentialType != webrtc.ICECredentialTypePassword {
t.Fatalf("TURN credential type wrong: %v", turn.CredentialType)
}
}

View file

@ -1,254 +0,0 @@
// Package wire implements the binary frame format used over the WebRTC
// DataChannel between the unarr daemon and the browser stream player.
//
// Header (12 bytes, big-endian):
//
// u8 Type
// u8 Flags
// u16 _reserved
// u32 StreamID -- multiplex range requests
// u32 Length -- payload bytes following the header
//
// Each side encodes one Frame at a time and writes it as a single SCTP
// message (DataChannel send). Browsers cap message size at 64 KiB-ish, so
// callers MUST split RANGE_DATA payloads into chunks <= MaxChunkPayload.
package wire
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
// FrameType identifies the wire message kind.
type FrameType uint8
const (
FrameHello FrameType = 0x00
FrameRangeReq FrameType = 0x01
FrameRangeData FrameType = 0x02
FrameRangeEnd FrameType = 0x03
FrameCancel FrameType = 0x04
FramePing FrameType = 0x05
FramePong FrameType = 0x06
FrameSeekHint FrameType = 0x07
)
// Flag bits — interpretation depends on FrameType.
const (
// FlagLastChunk on a RangeData frame marks the final chunk for a stream_id.
FlagLastChunk uint8 = 1 << 0
// FlagTranscoding on a Hello frame indicates the daemon will transcode.
FlagTranscoding uint8 = 1 << 1
// FlagSeekable on a Hello frame indicates random-access is supported.
FlagSeekable uint8 = 1 << 2
)
// HeaderSize is the fixed length of every frame header.
const HeaderSize = 12
// MaxChunkPayload is the safe per-frame payload cap that works on every
// browser implementation (Chromium fragments at 16 KiB internally above).
// Callers MUST chunk RangeData payloads to <= this size.
const MaxChunkPayload = 16 * 1024
// MaxFrameSize is the largest frame the parser will accept. Anything bigger
// is treated as a corrupted stream — close the channel.
const MaxFrameSize = HeaderSize + 64*1024
// Header is the parsed 12-byte frame header.
type Header struct {
Type FrameType
Flags uint8
StreamID uint32
Length uint32
}
// EncodeHeader writes h to dst (must be at least HeaderSize bytes).
func EncodeHeader(dst []byte, h Header) {
if len(dst) < HeaderSize {
panic("wire: dst too small for header")
}
dst[0] = byte(h.Type)
dst[1] = h.Flags
dst[2] = 0
dst[3] = 0
binary.BigEndian.PutUint32(dst[4:8], h.StreamID)
binary.BigEndian.PutUint32(dst[8:12], h.Length)
}
// DecodeHeader parses src (must be at least HeaderSize bytes) into h.
func DecodeHeader(src []byte) (Header, error) {
if len(src) < HeaderSize {
return Header{}, fmt.Errorf("wire: header needs %d bytes, got %d", HeaderSize, len(src))
}
h := Header{
Type: FrameType(src[0]),
Flags: src[1],
StreamID: binary.BigEndian.Uint32(src[4:8]),
Length: binary.BigEndian.Uint32(src[8:12]),
}
if h.Length > MaxFrameSize-HeaderSize {
return Header{}, fmt.Errorf("wire: payload length %d exceeds max %d", h.Length, MaxFrameSize-HeaderSize)
}
return h, nil
}
// EncodeFrame allocates and returns a complete frame (header + payload).
// Use this for one-shot sends; for hot-path RangeData prefer EncodeHeader
// into a pre-allocated buffer to avoid per-frame allocations.
func EncodeFrame(h Header, payload []byte) []byte {
if int(h.Length) != len(payload) {
panic(fmt.Sprintf("wire: header length %d != payload len %d", h.Length, len(payload)))
}
buf := make([]byte, HeaderSize+len(payload))
EncodeHeader(buf[:HeaderSize], h)
copy(buf[HeaderSize:], payload)
return buf
}
// ReadFrame reads one full frame from r. Returns the parsed header and a
// freshly allocated payload slice. On any size violation the connection
// must be closed — the protocol has no resync.
func ReadFrame(r io.Reader) (Header, []byte, error) {
headerBuf := make([]byte, HeaderSize)
if _, err := io.ReadFull(r, headerBuf); err != nil {
return Header{}, nil, err
}
h, err := DecodeHeader(headerBuf)
if err != nil {
return Header{}, nil, err
}
if h.Length == 0 {
return h, nil, nil
}
payload := make([]byte, h.Length)
if _, err := io.ReadFull(r, payload); err != nil {
return Header{}, nil, err
}
return h, payload, nil
}
// HelloPayload describes the file the daemon is about to serve. It is the
// first frame the daemon writes after the DataChannel opens.
type HelloPayload struct {
FileSize uint64
Transcoding bool
Seekable bool
FileName string
}
// EncodeHello marshals h into a payload byte slice.
//
// Layout: u64 file_size | u32 name_len | name_bytes
func EncodeHello(h HelloPayload) []byte {
nameBytes := []byte(h.FileName)
buf := make([]byte, 8+4+len(nameBytes))
binary.BigEndian.PutUint64(buf[0:8], h.FileSize)
binary.BigEndian.PutUint32(buf[8:12], uint32(len(nameBytes)))
copy(buf[12:], nameBytes)
return buf
}
// DecodeHello parses a Hello payload. The transcoding/seekable bits live in
// the frame Flags byte, not the payload — pass them in.
func DecodeHello(payload []byte, flags uint8) (HelloPayload, error) {
if len(payload) < 12 {
return HelloPayload{}, errors.New("wire: hello payload too short")
}
size := binary.BigEndian.Uint64(payload[0:8])
nameLen := binary.BigEndian.Uint32(payload[8:12])
if int(nameLen) > len(payload)-12 {
return HelloPayload{}, fmt.Errorf("wire: hello name_len %d exceeds payload", nameLen)
}
return HelloPayload{
FileSize: size,
Transcoding: flags&FlagTranscoding != 0,
Seekable: flags&FlagSeekable != 0,
FileName: string(payload[12 : 12+nameLen]),
}, nil
}
// HelloFlags returns the flag byte for a Hello frame given the booleans.
func HelloFlags(transcoding, seekable bool) uint8 {
var f uint8
if transcoding {
f |= FlagTranscoding
}
if seekable {
f |= FlagSeekable
}
return f
}
// RangeReqPayload is the browser → daemon request for bytes [Offset, Offset+Length).
type RangeReqPayload struct {
Offset uint64
Length uint64
}
// EncodeRangeReq marshals p. Layout: u64 offset | u64 length.
func EncodeRangeReq(p RangeReqPayload) []byte {
buf := make([]byte, 16)
binary.BigEndian.PutUint64(buf[0:8], p.Offset)
binary.BigEndian.PutUint64(buf[8:16], p.Length)
return buf
}
// DecodeRangeReq parses a 16-byte range request payload.
func DecodeRangeReq(payload []byte) (RangeReqPayload, error) {
if len(payload) != 16 {
return RangeReqPayload{}, fmt.Errorf("wire: range_req payload must be 16 bytes, got %d", len(payload))
}
return RangeReqPayload{
Offset: binary.BigEndian.Uint64(payload[0:8]),
Length: binary.BigEndian.Uint64(payload[8:16]),
}, nil
}
// RangeEndPayload signals end-of-response for a stream_id with a status code.
// Status 0 == OK; non-zero values are app-defined error codes.
type RangeEndPayload struct {
Status uint32
}
// EncodeRangeEnd marshals p.
func EncodeRangeEnd(p RangeEndPayload) []byte {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf[0:4], p.Status)
return buf
}
// DecodeRangeEnd parses a 4-byte range_end payload.
func DecodeRangeEnd(payload []byte) (RangeEndPayload, error) {
if len(payload) != 4 {
return RangeEndPayload{}, fmt.Errorf("wire: range_end payload must be 4 bytes, got %d", len(payload))
}
return RangeEndPayload{
Status: binary.BigEndian.Uint32(payload[0:4]),
}, nil
}
// SeekHintPayload tells the daemon a seek to timestamp_ms is imminent so it
// can pre-warm a transcoder pipeline before bytes are requested.
type SeekHintPayload struct {
TimestampMs uint64
}
// EncodeSeekHint marshals p.
func EncodeSeekHint(p SeekHintPayload) []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf[0:8], p.TimestampMs)
return buf
}
// DecodeSeekHint parses an 8-byte seek_hint payload.
func DecodeSeekHint(payload []byte) (SeekHintPayload, error) {
if len(payload) != 8 {
return SeekHintPayload{}, fmt.Errorf("wire: seek_hint payload must be 8 bytes, got %d", len(payload))
}
return SeekHintPayload{
TimestampMs: binary.BigEndian.Uint64(payload[0:8]),
}, nil
}

View file

@ -1,193 +0,0 @@
package wire
import (
"bytes"
"testing"
)
func TestHeaderRoundtrip(t *testing.T) {
cases := []Header{
{Type: FrameHello, Flags: FlagSeekable, StreamID: 0, Length: 32},
{Type: FrameRangeReq, Flags: 0, StreamID: 7, Length: 16},
{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 4242, Length: 16380},
{Type: FrameRangeEnd, Flags: 0, StreamID: 1, Length: 4},
{Type: FrameCancel, Flags: 0, StreamID: 9, Length: 0},
{Type: FramePing, Flags: 0, StreamID: 0, Length: 0},
}
for _, want := range cases {
buf := make([]byte, HeaderSize)
EncodeHeader(buf, want)
got, err := DecodeHeader(buf)
if err != nil {
t.Fatalf("decode: %v (want %+v)", err, want)
}
if got != want {
t.Errorf("roundtrip mismatch: got %+v want %+v", got, want)
}
}
}
func TestDecodeHeaderShort(t *testing.T) {
if _, err := DecodeHeader([]byte{0, 0, 0}); err == nil {
t.Fatal("expected error on short header")
}
}
func TestDecodeHeaderRejectsHugeLength(t *testing.T) {
// Synthesize a header with payload length above MaxFrameSize.
buf := make([]byte, HeaderSize)
buf[0] = byte(FrameRangeData)
buf[8] = 0xff
buf[9] = 0xff
buf[10] = 0xff
buf[11] = 0xff
if _, err := DecodeHeader(buf); err == nil {
t.Fatal("expected error on oversized payload length")
}
}
func TestEncodeFramePanicsOnLengthMismatch(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on header length / payload mismatch")
}
}()
EncodeFrame(Header{Type: FrameRangeData, Length: 5}, []byte{1, 2, 3})
}
func TestReadFrameRoundtrip(t *testing.T) {
want := Header{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 99, Length: 5}
payload := []byte{0xde, 0xad, 0xbe, 0xef, 0x42}
frame := EncodeFrame(want, payload)
r := bytes.NewReader(frame)
got, gotPayload, err := ReadFrame(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if !bytes.Equal(gotPayload, payload) {
t.Errorf("payload mismatch: %x want %x", gotPayload, payload)
}
}
func TestReadFrameZeroPayload(t *testing.T) {
want := Header{Type: FrameCancel, StreamID: 7}
frame := EncodeFrame(want, nil)
got, payload, err := ReadFrame(bytes.NewReader(frame))
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if len(payload) != 0 {
t.Errorf("expected empty payload, got %d bytes", len(payload))
}
}
func TestHelloRoundtrip(t *testing.T) {
want := HelloPayload{
FileSize: 1<<32 + 12345,
Transcoding: false,
Seekable: true,
FileName: "Tangled.Ever.After.2025.1080p.WEB-DL.h264.mp4",
}
flags := HelloFlags(want.Transcoding, want.Seekable)
payload := EncodeHello(want)
got, err := DecodeHello(payload, flags)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("hello mismatch: %+v want %+v", got, want)
}
}
func TestHelloRejectsTruncatedPayload(t *testing.T) {
if _, err := DecodeHello([]byte{1, 2, 3}, 0); err == nil {
t.Fatal("expected error on truncated hello")
}
}
func TestHelloRejectsNameLenOverrun(t *testing.T) {
// file_size + name_len=999 but no name bytes → should fail.
buf := make([]byte, 12)
buf[8], buf[9], buf[10], buf[11] = 0, 0, 0x03, 0xe7 // 999
if _, err := DecodeHello(buf, 0); err == nil {
t.Fatal("expected error on name_len overrun")
}
}
func TestRangeReqRoundtrip(t *testing.T) {
want := RangeReqPayload{Offset: 1 << 30, Length: 1 << 20}
got, err := DecodeRangeReq(EncodeRangeReq(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_req mismatch: %+v want %+v", got, want)
}
}
func TestRangeReqRejectsWrongLength(t *testing.T) {
if _, err := DecodeRangeReq(make([]byte, 15)); err == nil {
t.Fatal("expected error on 15-byte payload")
}
if _, err := DecodeRangeReq(make([]byte, 17)); err == nil {
t.Fatal("expected error on 17-byte payload")
}
}
func TestRangeEndRoundtrip(t *testing.T) {
want := RangeEndPayload{Status: 42}
got, err := DecodeRangeEnd(EncodeRangeEnd(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_end mismatch: %+v want %+v", got, want)
}
if _, err := DecodeRangeEnd(make([]byte, 3)); err == nil {
t.Fatal("expected error on short range_end payload")
}
}
func TestSeekHintRoundtrip(t *testing.T) {
want := SeekHintPayload{TimestampMs: 123_456}
got, err := DecodeSeekHint(EncodeSeekHint(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("seek_hint mismatch: %+v want %+v", got, want)
}
if _, err := DecodeSeekHint(make([]byte, 7)); err == nil {
t.Fatal("expected error on short seek_hint payload")
}
}
func TestHelloFlagsHelper(t *testing.T) {
if HelloFlags(false, false) != 0 {
t.Error("expected 0 for both false")
}
if HelloFlags(true, false) != FlagTranscoding {
t.Error("expected FlagTranscoding only")
}
if HelloFlags(false, true) != FlagSeekable {
t.Error("expected FlagSeekable only")
}
if HelloFlags(true, true) != (FlagTranscoding | FlagSeekable) {
t.Error("expected both flags")
}
}
// Sanity check that MaxChunkPayload + HeaderSize fits inside MaxFrameSize so
// callers can rely on the chunk cap without their own bookkeeping.
func TestMaxChunkFitsInMaxFrame(t *testing.T) {
if MaxChunkPayload+HeaderSize > MaxFrameSize {
t.Fatalf("chunk %d + hdr %d > max frame %d", MaxChunkPayload, HeaderSize, MaxFrameSize)
}
}

View file

@ -18,7 +18,7 @@ import (
// 5. Previously downloaded in the unarr cache dir
// 6. Auto-download static binary as last resort (~50MB, slow start)
//
// ffmpeg is required for the WebRTC streaming pipeline; ffprobe alone can't
// ffmpeg is required for the HLS streaming pipeline; ffprobe alone can't
// transcode HEVC/MKV to browser-friendly H.264/MP4 fragments.
func ResolveFFmpeg(explicit string) (string, error) {
if explicit != "" {