feat(stream)!: retire WebRTC, HLS-only, bump 0.9.4
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:
parent
9176e877eb
commit
ca7de23a56
33 changed files with 207 additions and 2854 deletions
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
64
internal/engine/transcode_quality.go
Normal file
64
internal/engine/transcode_quality.go
Normal 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"}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue