feat(seeding): wire seed ratio/time lifecycle into the torrent daemon

SeedRatio/SeedTime were declared on TorrentConfig but never consumed, and
SeedEnabled was hardcoded false in both constructors — the daemon never
seeded, and if forced it seeded forever.

- config: [downloads] seed_enabled/seed_ratio/seed_time (opt-in, off by default)
- daemon: parse seed_time + wire all three; startup log per target shape
- engine: seedTargetReached() (pure) + seedAndDrop() background monitor on a
  downloader-scoped seedCtx (not the task ctx, which dies when Download returns);
  drops the torrent on ratio (uploaded/size) OR time, whichever first; no target
  = seed until shutdown. Configurable check interval (tests lower it).
- fix: cleanup() now always drops — previously leaked the handle on error paths
  when seeding was enabled.
- refactor: dropTracked() helper shared by cleanup + post-seeding drop.

Tests: TestSeedTargetReached (9 cases) + ctx/no-target branches + loopback
swarm smoke (-tags smoke). Roadmap hueco closed.
This commit is contained in:
Deivid Soto 2026-06-01 10:30:39 +02:00
parent 665ec0a34f
commit 132c88b3f0
8 changed files with 459 additions and 34 deletions

View file

@ -69,7 +69,7 @@ desde la web. Diseño + set de opciones en el estado abajo.
### Huecos medios ⬜ ### Huecos medios ⬜
- ~~Sin gestión de espacio en disco (`Statfs`)~~**Pre-flight de espacio (2026-05-31)**`CheckDiskSpace` antes de cada descarga (torrent/usenet/debrid) con reserva configurable `downloads.min_free_disk_mb` (default 2048); manager NO hace fallback en disco lleno; aviso web 507 `INSUFFICIENT_DISK` al despachar (torrentclaw). Monitoreo mid-download diferido. Ver estado abajo. - ~~Sin gestión de espacio en disco (`Statfs`)~~**Pre-flight de espacio (2026-05-31)**`CheckDiskSpace` antes de cada descarga (torrent/usenet/debrid) con reserva configurable `downloads.min_free_disk_mb` (default 2048); manager NO hace fallback en disco lleno; aviso web 507 `INSUFFICIENT_DISK` al despachar (torrentclaw). Monitoreo mid-download diferido. Ver estado abajo.
- ~~Resume de torrent NO persiste reinicio del daemon~~**Auto-resume tras reinicio (2026-05-31)**`agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo. - ~~Resume de torrent NO persiste reinicio del daemon~~**Auto-resume tras reinicio (2026-05-31)**`agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo.
- Sin seeding/ratio lifecycle (flags existen, nadie los aplica). - ~~Sin seeding/ratio lifecycle (flags existen, nadie los aplica)~~ ✅ **Seeding/ratio lifecycle (2026-06-01)**`seed_enabled`/`seed_ratio`/`seed_time` en `[downloads]` (opt-in, off por defecto) cableados al daemon; al completar una descarga con seeding activo el torrent sigue subiendo en background y un monitor lo dropea al alcanzar ratio (subido/tamaño) O tiempo (lo primero que toque); sin target = siembra hasta apagado. `cleanup()` ahora siempre dropea (arregla fuga en rutas de error con seeding on). Verificado con swarm loopback real. Ver estado abajo.
- ~~Reproducir-mientras-baja: readahead estático 5MB~~**Readahead dinámico (2026-05-31)**`dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 896 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo. - ~~Reproducir-mientras-baja: readahead estático 5MB~~**Readahead dinámico (2026-05-31)**`dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 896 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo.
- ~~HDR→SDR sin tonemap~~**Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo. - ~~HDR→SDR sin tonemap~~**Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo.
- ~~Sin thumbnails~~**Fotogramas bajo demanda (2026-05-31)**`GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Ver estado abajo. - ~~Sin thumbnails~~**Fotogramas bajo demanda (2026-05-31)**`GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Ver estado abajo.
@ -128,6 +128,14 @@ Destapado durante el smoke de trickplay: el HLS de un 4K anamórfico (3840×1604
- **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1). - **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1).
- **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `<video>` carga (`readyState 4`, `duration 9313s`). Tests `H264LevelForFrame` (16:9 sin regresión + anamórfico → 5.0). - **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `<video>` carga (`readyState 4`, `duration 9313s`). Tests `H264LevelForFrame` (16:9 sin regresión + anamórfico → 5.0).
### Hueco medio — Seeding/ratio lifecycle ✅ CERRADO (2026-06-01)
Los flags `SeedRatio`/`SeedTime` (`TorrentConfig`) estaban DECLARADOS pero nadie los consumía, y `SeedEnabled` estaba hardcodeado a `false` en ambos constructores → el daemon nunca sembraba y, si se forzaba, sembraba para siempre.
- **Config** (`config.go`): `[downloads]` += `seed_enabled` (bool), `seed_ratio` (float), `seed_time` (string duración tipo `"24h"`). Opt-in, off por defecto (zero-values = apagado, sin entradas en `applyDefaults`). Tests `TestLoadSeeding{DefaultsOff,Explicit}`.
- **Wiring** (`daemon.go`): parsea `seed_time` (`time.ParseDuration`) y cablea los 3 campos a `TorrentConfig`; log de arranque que distingue ratio / tiempo / ambos / indefinido. El `unarr download` one-shot (foreground) sigue `SeedEnabled:false` a propósito (leech + exit; comentado).
- **Ciclo** (`torrent.go`): `seedTargetReached(ratio, time, uploaded, size, elapsed)` puro (ratio = subido/tamaño-seleccionado, estable entre resumes; el primero de ratio>0 o tiempo>0 que se cumple gana; ambos 0 = nunca para). `seedAndDrop` corre detached en un `seedCtx` propio del downloader (cancelado en `Shutdown`) — NO el ctx de la task, que se cancela en cuanto `Download` retorna y el manager libera el slot. Tick configurable (`seedCheckInterval`, default 30s; tests lo bajan). Sale sin dropear si el handle ya se quitó de `d.active` (cancel/pause del usuario) → ni lee stats de un torrent cerrado ni dropea dos veces.
- **Bug latente arreglado de paso**: `cleanup()` tenía `if !SeedEnabled { Drop }` — en rutas de ERROR (metadata timeout, disco, poll) con seeding activo borraba de `d.active` pero NO dropeaba → fuga. Ahora `cleanup()` siempre dropea (solo lo llama el error-path y el éxito-sin-seeding); el éxito-con-seeding hace el handoff a `seedAndDrop`.
- **Smoke real** (`seed_lifecycle_smoke_test.go`, tag `smoke`): swarm loopback de dos clientes anacrolix (un seeder sirviendo 4 MiB + nuestro `TorrentDownloader` leecheándolo vía `AddClientPeer`). Tras completar (4194304 bytes reales transferidos), `seedAndDrop` con `SeedTime=1s` dispara el target de tiempo (`seed time 1s reached, uploaded 0 B — dropping`) y quita el torrent de `d.active`. Verifica el path real Stats/Drop/ticker, no mocks. Tests puros `TestSeedTargetReached` (9 casos: ratio/tiempo/ninguno/ambos/guarda-tamaño-0) + `TestTorrentDownloader_SeedRatioTime`.
### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (2026-05-31) ### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (2026-05-31)
HDR (HDR10/HLG/DV) transcodificado a SDR salía lavado/desaturado (sin tonemap). Ahora `buildHLSFFmpegArgsAt` inserta `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv` tras el scale y antes de `format=`, cuando `probe.HDR != "" && Transcode.TonemapHDR`. HDR (HDR10/HLG/DV) transcodificado a SDR salía lavado/desaturado (sin tonemap). Ahora `buildHLSFFmpegArgsAt` inserta `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv` tras el scale y antes de `format=`, cuando `probe.HDR != "" && Transcode.TonemapHDR`.
- **Gate por capacidad**: `FFmpegSupportsZscale(ffmpegPath)` (cacheado, `ffmpeg -filters`) → solo activa si el build trae zscale/zimg. Sin zscale → no se inserta (la fuente sigue reproduciéndose, desaturada — no rompe). `transcoder.go:270` ya advertía que builds sin zimg no pueden tonemapear; el static ffbinaries puede faltarle, pero `/usr/bin/ffmpeg` (distro) y el docker sí lo traen. - **Gate por capacidad**: `FFmpegSupportsZscale(ffmpegPath)` (cacheado, `ffmpeg -filters`) → solo activa si el build trae zscale/zimg. Sin zscale → no se inserta (la fuente sigue reproduciéndose, desaturada — no rompe). `transcoder.go:270` ya advertía que builds sin zimg no pueden tonemapear; el static ffbinaries puede faltarle, pero `/usr/bin/ffmpeg` (distro) y el docker sí lo traen.

View file

@ -204,6 +204,9 @@ func runDaemonStart() error {
metaTimeout, _ := time.ParseDuration(cfg.Download.MetadataTimeout) metaTimeout, _ := time.ParseDuration(cfg.Download.MetadataTimeout)
stallTimeout, _ := time.ParseDuration(cfg.Download.StallTimeout) stallTimeout, _ := time.ParseDuration(cfg.Download.StallTimeout)
// Parse the seeding time target (0/"" = no time target — ratio-only or forever)
seedTime, _ := time.ParseDuration(cfg.Download.SeedTime)
// Create progress reporter — only used for stream tasks (handleStreamTask) // Create progress reporter — only used for stream tasks (handleStreamTask)
// The sync goroutine handles all regular progress reporting. // The sync goroutine handles all regular progress reporting.
statusInterval, _ := time.ParseDuration(cfg.Daemon.StatusInterval) statusInterval, _ := time.ParseDuration(cfg.Daemon.StatusInterval)
@ -273,7 +276,9 @@ func runDaemonStart() error {
MaxDownloadRate: maxDl, MaxDownloadRate: maxDl,
MaxUploadRate: maxUl, MaxUploadRate: maxUl,
ListenPort: cfg.Download.ListenPort, ListenPort: cfg.Download.ListenPort,
SeedEnabled: false, SeedEnabled: cfg.Download.SeedEnabled,
SeedRatio: cfg.Download.SeedRatio,
SeedTime: seedTime,
VPNTunnel: vpnTunnel, VPNTunnel: vpnTunnel,
}) })
if err != nil { if err != nil {
@ -291,6 +296,19 @@ func runDaemonStart() error {
log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr) log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr)
} }
if cfg.Download.SeedEnabled {
switch {
case cfg.Download.SeedRatio > 0 && seedTime > 0:
log.Printf("[torrent] seeding enabled (stop at ratio %.2f or %s, whichever first)", cfg.Download.SeedRatio, seedTime)
case cfg.Download.SeedRatio > 0:
log.Printf("[torrent] seeding enabled (stop at ratio %.2f)", cfg.Download.SeedRatio)
case seedTime > 0:
log.Printf("[torrent] seeding enabled (stop after %s)", seedTime)
default:
log.Printf("[torrent] seeding enabled (no ratio/time target — seeds until shutdown)")
}
}
// Create debrid downloader // Create debrid downloader
debridDl := engine.NewDebridDownloader() debridDl := engine.NewDebridDownloader()
usenetDl := engine.NewUsenetDownloader(agentClient) usenetDl := engine.NewUsenetDownloader(agentClient)

View file

@ -113,6 +113,8 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
MetadataTimeout: 15 * time.Minute, MetadataTimeout: 15 * time.Minute,
StallTimeout: 10 * time.Minute, StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited MaxTimeout: 0, // unlimited
// One-shot foreground download: leech then exit. Seeding only makes sense
// for the always-on daemon (see DownloadConfig.SeedEnabled).
SeedEnabled: false, SeedEnabled: false,
}) })
if err != nil { if err != nil {

View file

@ -46,6 +46,12 @@ type DownloadConfig struct {
MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
// Seeding lifecycle (BitTorrent only). Off by default — the daemon leeches
// then drops the torrent. Enable to keep uploading after a download finishes;
// seeding stops at whichever target is hit first, or never if both are unset.
SeedEnabled bool `toml:"seed_enabled"` // keep uploading after completion (default: false)
SeedRatio float64 `toml:"seed_ratio"` // stop once uploaded/size reaches this ratio (0 = no ratio target)
SeedTime string `toml:"seed_time"` // stop after this long since completion, e.g. "24h" (0/"" = no time target)
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)

View file

@ -246,6 +246,55 @@ enabled = false
} }
} }
func TestLoadSeedingDefaultsOff(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// No [downloads] seeding keys — seeding must stay off by default.
os.WriteFile(path, []byte(`[auth]
api_key = "tc_x"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.SeedEnabled {
t.Error("SeedEnabled should default to false")
}
if cfg.Download.SeedRatio != 0 {
t.Errorf("SeedRatio = %v, want 0", cfg.Download.SeedRatio)
}
if cfg.Download.SeedTime != "" {
t.Errorf("SeedTime = %q, want empty", cfg.Download.SeedTime)
}
}
func TestLoadSeedingExplicit(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
os.WriteFile(path, []byte(`[downloads]
seed_enabled = true
seed_ratio = 2.0
seed_time = "24h"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if !cfg.Download.SeedEnabled {
t.Error("SeedEnabled = false, want true")
}
if cfg.Download.SeedRatio != 2.0 {
t.Errorf("SeedRatio = %v, want 2.0", cfg.Download.SeedRatio)
}
if cfg.Download.SeedTime != "24h" {
t.Errorf("SeedTime = %q, want 24h", cfg.Download.SeedTime)
}
}
func TestLoadInvalidTOML(t *testing.T) { func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml") path := filepath.Join(tmp, "config.toml")

View file

@ -0,0 +1,118 @@
//go:build smoke
package engine
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// TestSeedLifecycleSmoke spins up a real loopback BitTorrent swarm: a seeder
// client serving a small file, and our TorrentDownloader's client leeching it.
// Once the leecher completes, the torrent is handed to seedAndDrop with a short
// SeedTime; the test asserts the lifecycle fires and the handle is dropped
// (removed from d.active). Exercises the real anacrolix Stats/Drop/ticker path,
// not mocks. Run with: go test -tags smoke -run TestSeedLifecycleSmoke ./internal/engine/
func TestSeedLifecycleSmoke(t *testing.T) {
// --- seeder: a real client serving a 4 MiB file over loopback ---
seedDir := t.TempDir()
payload := make([]byte, 4<<20)
for i := range payload {
payload[i] = byte(i)
}
if err := os.WriteFile(filepath.Join(seedDir, "movie.bin"), payload, 0o644); err != nil {
t.Fatal(err)
}
var info metainfo.Info
info.PieceLength = 256 << 10
if err := info.BuildFromFilePath(filepath.Join(seedDir, "movie.bin")); err != nil {
t.Fatalf("build info: %v", err)
}
var mi metainfo.MetaInfo
var err error
if mi.InfoBytes, err = bencode.Marshal(info); err != nil {
t.Fatalf("marshal info: %v", err)
}
scfg := torrent.NewDefaultClientConfig()
scfg.DataDir = seedDir
scfg.Seed = true
scfg.NoDHT = true
scfg.DisableTrackers = true
scfg.ListenPort = 0 // random — never collides with the leecher's 42069
seeder, err := torrent.NewClient(scfg)
if err != nil {
t.Fatalf("seeder client: %v", err)
}
defer seeder.Close()
st, err := seeder.AddTorrent(&mi)
if err != nil {
t.Fatalf("seeder add: %v", err)
}
<-st.GotInfo()
st.DownloadAll() // verifies the existing pieces so the seeder is "complete"
// --- leecher: our downloader, seeding enabled, very short seed time ---
leechDir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: leechDir,
SeedEnabled: true,
SeedTime: 1 * time.Second, // time target fires fast (no peers pull from us, so ratio stays 0)
})
if err != nil {
t.Fatalf("downloader: %v", err)
}
dl.seedCheckInterval = 200 * time.Millisecond // poll fast so the 1s target is noticed promptly
defer dl.Shutdown(context.Background())
lt, err := dl.client.AddTorrent(&mi)
if err != nil {
t.Fatalf("leecher add: %v", err)
}
<-lt.GotInfo()
lt.AddClientPeer(seeder) // loopback peer — no DHT/tracker needed
lt.DownloadAll()
deadline := time.After(30 * time.Second)
for lt.BytesMissing() > 0 {
select {
case <-deadline:
t.Fatalf("download did not complete (missing %d bytes)", lt.BytesMissing())
case <-time.After(100 * time.Millisecond):
}
}
t.Logf("leecher completed %d bytes", lt.BytesCompleted())
// Track it as the daemon would for a seeding torrent, then run the lifecycle.
const taskID = "smoke-seed-task-0001"
dl.activeMu.Lock()
dl.active[taskID] = lt
dl.activeMu.Unlock()
done := make(chan struct{})
go func() {
dl.seedAndDrop(taskID, lt, info.Length)
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("seedAndDrop did not return within 10s")
}
dl.activeMu.Lock()
_, stillTracked := dl.active[taskID]
dl.activeMu.Unlock()
if stillTracked {
t.Error("torrent still tracked after seedAndDrop — lifecycle did not drop it")
}
}

View file

@ -16,6 +16,7 @@ import (
alog "github.com/anacrolix/log" alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent" "github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage" "github.com/anacrolix/torrent/storage"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn" "github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term" "golang.org/x/term"
@ -91,6 +92,15 @@ type TorrentDownloader struct {
activeMu sync.Mutex activeMu sync.Mutex
active map[string]*torrent.Torrent // taskID -> torrent handle active map[string]*torrent.Torrent // taskID -> torrent handle
// seedCtx scopes the background seeders. Cancelled at Shutdown so they stop
// uploading and exit; it must outlive any single download's task context
// (which is cancelled the moment Download returns and the queue slot frees).
seedCtx context.Context
seedCancel context.CancelFunc
// seedCheckInterval is how often the background seeder re-checks its stop
// condition. Defaults to defaultSeedCheckInterval; tests lower it.
seedCheckInterval time.Duration
minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled) minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
} }
@ -278,10 +288,14 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
} }
} }
seedCtx, seedCancel := context.WithCancel(context.Background())
return &TorrentDownloader{ return &TorrentDownloader{
client: client, client: client,
cfg: cfg, cfg: cfg,
active: make(map[string]*torrent.Torrent), active: make(map[string]*torrent.Torrent),
seedCtx: seedCtx,
seedCancel: seedCancel,
seedCheckInterval: defaultSeedCheckInterval,
}, nil }, nil
} }
@ -304,14 +318,11 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
d.active[task.ID] = t d.active[task.ID] = t
d.activeMu.Unlock() d.activeMu.Unlock()
cleanup := func() { // cleanup drops the torrent and stops tracking it. Used by every error path
d.activeMu.Lock() // (metadata timeout, disk guard, poll failure) and by the non-seeding success
delete(d.active, task.ID) // path — all of which must drop. The seeding success path deliberately does
d.activeMu.Unlock() // NOT call cleanup (it hands off to seedAndDrop).
if !d.cfg.SeedEnabled { cleanup := func() { d.dropTracked(task.ID, t) }
t.Drop()
}
}
// 1. Wait for metadata (0 = unlimited, like qBittorrent) // 1. Wait for metadata (0 = unlimited, like qBittorrent)
if d.cfg.MetadataTimeout > 0 { if d.cfg.MetadataTimeout > 0 {
@ -396,9 +407,14 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
// readable mode is preserved through the rename. // readable mode is preserved through the rename.
makeReadable(filePath) makeReadable(filePath)
// If seeding enabled, keep alive (don't cleanup). // Seeding handoff: with seeding enabled, keep the torrent uploading in the
// The manager handles seeding lifecycle. // background — seedAndDrop drops it once the ratio/time target is hit (or at
if !d.cfg.SeedEnabled { // shutdown). Otherwise drop now. seedAndDrop must NOT use ctx: the task
// context is cancelled the moment Download returns and the manager frees the
// queue slot, which would kill the seeder instantly.
if d.cfg.SeedEnabled {
go d.seedAndDrop(task.ID, t, totalBytes)
} else {
cleanup() cleanup()
} }
@ -503,6 +519,97 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
} }
} }
// dropTracked stops tracking taskID and drops the torrent handle. The delete is
// guarded on the entry still being this handle, so a concurrent Pause/Cancel that
// already removed/replaced it isn't clobbered; t.Drop() is idempotent. Shared by
// the error/non-seeding cleanup path and the post-seeding drop.
func (d *TorrentDownloader) dropTracked(taskID string, t *torrent.Torrent) {
d.activeMu.Lock()
if cur, ok := d.active[taskID]; ok && cur == t {
delete(d.active, taskID)
}
d.activeMu.Unlock()
t.Drop()
}
// defaultSeedCheckInterval is how often the background seeder re-evaluates the
// ratio / time stop condition. Seeding is long-running and low-urgency, so a
// coarse interval keeps the overhead negligible. Stored on the downloader so
// tests can lower it.
const defaultSeedCheckInterval = 30 * time.Second
// seedTargetReached reports why seeding should stop, or "" to keep going.
// Ratio is uploaded-data / selected-size ("uploaded N× the content"), which is
// stable across resumes — unlike uploaded/downloaded-this-session. The two
// targets are independent: whichever of ratio (>0) or time (>0) fires first
// wins; with both unset nothing ever fires (the caller seeds indefinitely).
func seedTargetReached(ratioTarget float64, timeTarget time.Duration, uploaded, size int64, elapsed time.Duration) string {
var ratio float64
if size > 0 {
ratio = float64(uploaded) / float64(size)
}
switch {
case ratioTarget > 0 && ratio >= ratioTarget:
return fmt.Sprintf("ratio %.2f reached (target %.2f)", ratio, ratioTarget)
case timeTarget > 0 && elapsed >= timeTarget:
return fmt.Sprintf("seed time %s reached (target %s)", elapsed.Round(time.Second), timeTarget)
}
return ""
}
// seedAndDrop keeps a completed torrent uploading until the configured ratio or
// time target is reached, then drops it (stops seeding, releases the handle and
// its queue tracking). Runs detached on d.seedCtx — see the Download call site
// for why it can't use the task context. With no ratio/time target it returns
// immediately and the torrent seeds until Shutdown (or a user cancel/pause drops
// it). It exits without dropping if the handle was already removed elsewhere, so
// it never reads stats off a closed torrent nor double-drops.
func (d *TorrentDownloader) seedAndDrop(taskID string, t *torrent.Torrent, totalBytes int64) {
sid := agent.ShortID(taskID)
ratioTarget := d.cfg.SeedRatio
timeTarget := d.cfg.SeedTime
if ratioTarget <= 0 && timeTarget <= 0 {
log.Printf("[%s] seeding indefinitely (no ratio/time target) — drops at shutdown", sid)
return
}
log.Printf("[%s] seeding (ratio target: %.2f, time target: %s)", sid, ratioTarget, timeTarget)
interval := d.seedCheckInterval
if interval <= 0 {
interval = defaultSeedCheckInterval
}
start := time.Now()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-d.seedCtx.Done():
return // daemon shutting down — Shutdown drops the handle
case <-ticker.C:
// Bail if the handle was dropped elsewhere (user cancel/pause).
d.activeMu.Lock()
cur, ok := d.active[taskID]
d.activeMu.Unlock()
if !ok || cur != t {
return
}
stats := t.Stats()
uploaded := stats.BytesWrittenData.Int64()
reason := seedTargetReached(ratioTarget, timeTarget, uploaded, totalBytes, time.Since(start))
if reason == "" {
continue
}
log.Printf("[%s] seeding complete: %s, uploaded %s — dropping", sid, reason, formatBytes(uploaded))
d.dropTracked(taskID, t)
return
}
}
}
// makeReadable relaxes permissions on a completed download so it can be // makeReadable relaxes permissions on a completed download so it can be
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates // re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
// files with mode 0000; we set files to 0644 and directories to 0755. Errors // files with mode 0000; we set files to 0644 and directories to 0755. Errors
@ -588,6 +695,12 @@ func (d *TorrentDownloader) Cancel(taskID string) error {
} }
func (d *TorrentDownloader) Shutdown(ctx context.Context) error { func (d *TorrentDownloader) Shutdown(ctx context.Context) error {
// Stop background seeders first so they don't read stats off / re-drop the
// handles we're about to close.
if d.seedCancel != nil {
d.seedCancel()
}
// Save DHT nodes in binary format for next session (warm start) // Save DHT nodes in binary format for next session (warm start)
saveDhtNodesBinary(d.client) saveDhtNodesBinary(d.client)

View file

@ -264,3 +264,114 @@ func TestTorrentDownloader_DownloadTimeout_MetadataCancel(t *testing.T) {
func TestTorrentDownloader_ImplementsInterface(t *testing.T) { func TestTorrentDownloader_ImplementsInterface(t *testing.T) {
var _ Downloader = (*TorrentDownloader)(nil) var _ Downloader = (*TorrentDownloader)(nil)
} }
// TestSeedTargetReached cubre la lógica pura de parada del seeding: ratio,
// tiempo, ninguno, ambos (el primero que se cumple gana) y la guarda de tamaño
// cero (no debe dividir por cero ni parar por ratio).
func TestSeedTargetReached(t *testing.T) {
tests := []struct {
name string
ratioTarget float64
timeTarget time.Duration
uploaded int64
size int64
elapsed time.Duration
wantStop bool
}{
{"ratio reached", 2.0, 0, 200, 100, time.Minute, true},
{"ratio not reached", 2.0, 0, 150, 100, time.Minute, false},
{"ratio exactly met", 1.0, 0, 100, 100, time.Minute, true},
{"time reached", 0, time.Hour, 10, 100, 2 * time.Hour, true},
{"time not reached", 0, time.Hour, 10, 100, 30 * time.Minute, false},
{"no targets never stops", 0, 0, 9999, 100, 99 * time.Hour, false},
{"ratio wins when both set", 2.0, time.Hour, 200, 100, time.Second, true},
{"time wins when ratio short", 5.0, time.Hour, 100, 100, 2 * time.Hour, true},
{"zero size guards div", 2.0, 0, 200, 0, time.Minute, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
reason := seedTargetReached(tc.ratioTarget, tc.timeTarget, tc.uploaded, tc.size, tc.elapsed)
if got := reason != ""; got != tc.wantStop {
t.Errorf("seedTargetReached(ratio=%.1f time=%s up=%d size=%d el=%s) stop=%v (reason %q), want %v",
tc.ratioTarget, tc.timeTarget, tc.uploaded, tc.size, tc.elapsed, got, reason, tc.wantStop)
}
})
}
}
// TestTorrentDownloader_SeedRatioTime verifica que SeedRatio y SeedTime se
// propagan a la config del downloader.
func TestTorrentDownloader_SeedRatioTime(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
SeedEnabled: true,
SeedRatio: 1.5,
SeedTime: 2 * time.Hour,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if dl.cfg.SeedRatio != 1.5 {
t.Errorf("SeedRatio = %v, want 1.5", dl.cfg.SeedRatio)
}
if dl.cfg.SeedTime != 2*time.Hour {
t.Errorf("SeedTime = %v, want 2h", dl.cfg.SeedTime)
}
if dl.seedCtx == nil || dl.seedCancel == nil {
t.Error("seedCtx/seedCancel must be initialised by the constructor")
}
}
// TestSeedAndDrop_NoTargetReturnsImmediately verifica que sin ratio ni tiempo
// objetivo, seedAndDrop retorna de inmediato (siembra indefinida) sin tocar el
// handle — por eso es seguro pasar un torrent nil.
func TestSeedAndDrop_NoTargetReturnsImmediately(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir, SeedEnabled: true}) // ratio 0, time 0
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
done := make(chan struct{})
go func() {
dl.seedAndDrop("no-target-task-id", nil, 1000)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("seedAndDrop with no target should return immediately")
}
}
// TestSeedAndDrop_StopsOnSeedCtxCancel verifica que seedAndDrop sale cuando se
// cancela seedCtx (ruta de Shutdown), incluso con un objetivo de ratio alto y el
// tick deshabilitado — el único camino de salida es seedCtx.Done().
func TestSeedAndDrop_StopsOnSeedCtxCancel(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir, SeedEnabled: true, SeedRatio: 99})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
dl.seedCheckInterval = time.Hour // el ticker no disparará; solo seedCtx.Done() puede terminar
dl.seedCancel() // cancela antes de arrancar el monitor
done := make(chan struct{})
go func() {
dl.seedAndDrop("ctx-cancel-task-id", nil, 1000)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("seedAndDrop should return when seedCtx is cancelled")
}
}