feat(seed-file): unarr-side handler for browser-on-demand seeding (Fase 4.7.c)

Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.

Pieces:

- internal/agent/types.go: Task.FilePath (received from claim) +
  StatusUpdate.InfoHash (sent back). Both serialise compatibly with
  the matching Zod schemas in the Next.js sync route.

- internal/engine/seed_file.go: SeedFile(client, filePath, trackers)
  builds the metainfo via metainfo.Info.BuildFromFilePath +
  bencode.Marshal, then AddTorrent + DownloadAll() so anacrolix
  hashes the file and flips pieces to "have" as it goes. The
  libtorrent piece-size ladder is mirrored from wstracker-probe so
  generated torrents are interoperable with mainstream clients.
  SeedFileOnDownloader is the daemon-facing convenience wrapper —
  bails loud when [downloads.webrtc].enabled = false instead of
  silently producing a torrent no browser can find.

- internal/cmd/seed_file_handler.go: handleSeedFileTask invoked from
  the existing OnTasksClaimed dispatcher in daemon.go for mode=
  seed_file. Validates filePath, calls the engine helper, and pushes
  the resulting info_hash via Client.ReportStatus. Failures (missing
  file, WebRTC disabled, ffmpeg-style oddities) report status="failed"
  + errorMessage so the browser's WatchInBrowserButton can show the
  reason instead of timing out at 60 s.

- internal/cmd/daemon.go: dispatcher learns the seed_file branch in
  the same shape as the existing stream branch.

Tests (6 unit, all green):
- SeedFile rejects missing files + directories.
- SeedFile yields a deterministic info_hash for the same payload across
  fresh clients (web client polls expecting this).
- SeedFileOnDownloader errors when WebRTC is disabled.
- chooseSeedPieceLength matches the ladder breakpoints.
- makeAnnounceList handles nil/empty/partial inputs.

Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
This commit is contained in:
Deivid Soto 2026-05-06 16:28:01 +02:00
parent 2aeabe6b50
commit e50dd17a00
5 changed files with 383 additions and 1 deletions

View file

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

View file

@ -0,0 +1,164 @@
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)
}
}