unarr/internal/cmd/seed_file_handler.go
Deivid Soto e50dd17a00 feat(seed-file): unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.

Pieces:

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

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

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

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

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

Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
2026-05-06 16:28:01 +02:00

65 lines
2.1 KiB
Go

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)
}
}