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:
parent
665ec0a34f
commit
132c88b3f0
8 changed files with 459 additions and 34 deletions
|
|
@ -204,6 +204,9 @@ func runDaemonStart() error {
|
|||
metaTimeout, _ := time.ParseDuration(cfg.Download.MetadataTimeout)
|
||||
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)
|
||||
// The sync goroutine handles all regular progress reporting.
|
||||
statusInterval, _ := time.ParseDuration(cfg.Daemon.StatusInterval)
|
||||
|
|
@ -273,7 +276,9 @@ func runDaemonStart() error {
|
|||
MaxDownloadRate: maxDl,
|
||||
MaxUploadRate: maxUl,
|
||||
ListenPort: cfg.Download.ListenPort,
|
||||
SeedEnabled: false,
|
||||
SeedEnabled: cfg.Download.SeedEnabled,
|
||||
SeedRatio: cfg.Download.SeedRatio,
|
||||
SeedTime: seedTime,
|
||||
VPNTunnel: vpnTunnel,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -291,6 +296,19 @@ func runDaemonStart() error {
|
|||
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
|
||||
debridDl := engine.NewDebridDownloader()
|
||||
usenetDl := engine.NewUsenetDownloader(agentClient)
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
|
|||
MetadataTimeout: 15 * time.Minute,
|
||||
StallTimeout: 10 * time.Minute,
|
||||
MaxTimeout: 0, // unlimited
|
||||
SeedEnabled: false,
|
||||
// One-shot foreground download: leech then exit. Seeding only makes sense
|
||||
// for the always-on daemon (see DownloadConfig.SeedEnabled).
|
||||
SeedEnabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create downloader: %w", err)
|
||||
|
|
|
|||
|
|
@ -43,14 +43,20 @@ type DownloadConfig struct {
|
|||
PreferredMethod string `toml:"preferred_method"`
|
||||
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
||||
MaxConcurrent int `toml:"max_concurrent"`
|
||||
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
|
||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
||||
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")
|
||||
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
||||
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)
|
||||
// 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")
|
||||
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)
|
||||
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)
|
||||
// RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a
|
||||
// signed, short-lived token embedded in the URLs the agent reports. Default
|
||||
// true (secure by default); loopback callers (local mpv/vlc) are always exempt.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "config.toml")
|
||||
|
|
|
|||
118
internal/engine/seed_lifecycle_smoke_test.go
Normal file
118
internal/engine/seed_lifecycle_smoke_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import (
|
|||
alog "github.com/anacrolix/log"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
"github.com/torrentclaw/unarr/internal/vpn"
|
||||
"golang.org/x/term"
|
||||
|
|
@ -61,21 +62,21 @@ var defaultTrackers = []string{
|
|||
|
||||
// TorrentConfig holds settings for the BitTorrent downloader.
|
||||
type TorrentConfig struct {
|
||||
DataDir string
|
||||
DataDir string
|
||||
// PieceCompletionDir, when non-empty, stores the piece-completion SQLite DB
|
||||
// in this directory instead of DataDir. Use the agent's local state dir
|
||||
// (not the download dir) so the DB never lands on NFS/SMB volumes where
|
||||
// SQLite locking times out.
|
||||
PieceCompletionDir string
|
||||
MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
|
||||
StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
|
||||
MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
|
||||
MaxDownloadRate int64 // bytes/s, 0 = unlimited
|
||||
MaxUploadRate int64 // bytes/s, 0 = unlimited
|
||||
ListenPort int // fixed port for incoming peers (default 42069, 0 = random)
|
||||
SeedEnabled bool
|
||||
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
|
||||
SeedTime time.Duration // min seed time after completion (default 0)
|
||||
PieceCompletionDir string
|
||||
MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
|
||||
StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
|
||||
MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
|
||||
MaxDownloadRate int64 // bytes/s, 0 = unlimited
|
||||
MaxUploadRate int64 // bytes/s, 0 = unlimited
|
||||
ListenPort int // fixed port for incoming peers (default 42069, 0 = random)
|
||||
SeedEnabled bool
|
||||
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
|
||||
SeedTime time.Duration // min seed time after completion (default 0)
|
||||
|
||||
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
|
||||
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
|
||||
|
|
@ -91,6 +92,15 @@ type TorrentDownloader struct {
|
|||
activeMu sync.Mutex
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -278,10 +288,14 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
|||
}
|
||||
}
|
||||
|
||||
seedCtx, seedCancel := context.WithCancel(context.Background())
|
||||
return &TorrentDownloader{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
active: make(map[string]*torrent.Torrent),
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
active: make(map[string]*torrent.Torrent),
|
||||
seedCtx: seedCtx,
|
||||
seedCancel: seedCancel,
|
||||
seedCheckInterval: defaultSeedCheckInterval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -304,14 +318,11 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
|
|||
d.active[task.ID] = t
|
||||
d.activeMu.Unlock()
|
||||
|
||||
cleanup := func() {
|
||||
d.activeMu.Lock()
|
||||
delete(d.active, task.ID)
|
||||
d.activeMu.Unlock()
|
||||
if !d.cfg.SeedEnabled {
|
||||
t.Drop()
|
||||
}
|
||||
}
|
||||
// cleanup drops the torrent and stops tracking it. Used by every error path
|
||||
// (metadata timeout, disk guard, poll failure) and by the non-seeding success
|
||||
// path — all of which must drop. The seeding success path deliberately does
|
||||
// NOT call cleanup (it hands off to seedAndDrop).
|
||||
cleanup := func() { d.dropTracked(task.ID, t) }
|
||||
|
||||
// 1. Wait for metadata (0 = unlimited, like qBittorrent)
|
||||
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.
|
||||
makeReadable(filePath)
|
||||
|
||||
// If seeding enabled, keep alive (don't cleanup).
|
||||
// The manager handles seeding lifecycle.
|
||||
if !d.cfg.SeedEnabled {
|
||||
// Seeding handoff: with seeding enabled, keep the torrent uploading in the
|
||||
// background — seedAndDrop drops it once the ratio/time target is hit (or at
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
|
||||
// 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 {
|
||||
// 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)
|
||||
saveDhtNodesBinary(d.client)
|
||||
|
||||
|
|
|
|||
|
|
@ -264,3 +264,114 @@ func TestTorrentDownloader_DownloadTimeout_MetadataCancel(t *testing.T) {
|
|||
func TestTorrentDownloader_ImplementsInterface(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue