feat(daemon): add auto-scan, force start, and stall timeout default
- Auto-scan: daemon scans library daily (configurable via config.toml) [library] auto_scan = true, scan_interval = "24h" - Force start: tasks with forceStart=true bypass concurrency semaphore (like Transmission's Force Start — opens temporary extra slot) - Stall timeout default: 30m instead of unlimited, prevents dead torrents from permanently blocking download slots - ForceStart field in agent.Task for CLI/server communication
This commit is contained in:
parent
386c97f84a
commit
c476bd865c
5 changed files with 167 additions and 6 deletions
|
|
@ -70,6 +70,7 @@ type Task struct {
|
||||||
NzbPassword string `json:"nzbPassword,omitempty"` // Password for encrypted NZB archives
|
NzbPassword string `json:"nzbPassword,omitempty"` // Password for encrypted NZB archives
|
||||||
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
|
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
|
||||||
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
|
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
|
||||||
|
ForceStart bool `json:"forceStart,omitempty"` // Bypass queue (like Transmission's Force Start)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TasksResponse wraps the array of tasks returned by the server.
|
// TasksResponse wraps the array of tasks returned by the server.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
|
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
|
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
|
||||||
|
"github.com/torrentclaw/torrentclaw-cli/internal/library"
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/usenet/download"
|
"github.com/torrentclaw/torrentclaw-cli/internal/usenet/download"
|
||||||
"github.com/torrentclaw/torrentclaw-cli/internal/upgrade"
|
"github.com/torrentclaw/torrentclaw-cli/internal/upgrade"
|
||||||
)
|
)
|
||||||
|
|
@ -438,6 +439,17 @@ func runDaemonStart() error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Start auto-scan goroutine (daily library scan + sync)
|
||||||
|
if cfg.Library.ScanPath != "" && cfg.Library.AutoScan {
|
||||||
|
scanInterval := 24 * time.Hour
|
||||||
|
if cfg.Library.ScanInterval != "" {
|
||||||
|
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
||||||
|
scanInterval = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go runAutoScan(ctx, cfg, scanInterval)
|
||||||
|
}
|
||||||
|
|
||||||
// Start daemon (blocks)
|
// Start daemon (blocks)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -511,3 +523,130 @@ func formatSpeedLog(bps int64) string {
|
||||||
return fmt.Sprintf("%d B/s", bps)
|
return fmt.Sprintf("%d B/s", bps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runAutoScan runs a library scan + sync on a timer.
|
||||||
|
func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration) {
|
||||||
|
log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath)
|
||||||
|
|
||||||
|
// Run first scan after a short delay (let daemon stabilize)
|
||||||
|
select {
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doScan := func() {
|
||||||
|
log.Printf("[auto-scan] starting scan of %s", cfg.Library.ScanPath)
|
||||||
|
|
||||||
|
existing, _ := library.LoadCache()
|
||||||
|
|
||||||
|
workers := cfg.Library.Workers
|
||||||
|
if workers == 0 {
|
||||||
|
workers = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
cache, err := library.Scan(ctx, cfg.Library.ScanPath, existing, library.ScanOptions{
|
||||||
|
Workers: workers,
|
||||||
|
FFprobePath: cfg.Library.FFprobePath,
|
||||||
|
Incremental: existing != nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auto-scan] scan failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := library.SaveCache(cache); err != nil {
|
||||||
|
log.Printf("[auto-scan] save cache failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to server
|
||||||
|
apiKey := cfg.Auth.APIKey
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("[auto-scan] no API key, skipping sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
|
||||||
|
items := buildSyncItems(cache)
|
||||||
|
if len(items) == 0 {
|
||||||
|
log.Printf("[auto-scan] no items to sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100
|
||||||
|
for i := 0; i < len(items); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
isLast := end >= len(items)
|
||||||
|
|
||||||
|
_, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
||||||
|
Items: items[i:end],
|
||||||
|
ScanPath: cache.Path,
|
||||||
|
IsLastBatch: isLast,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auto-scan] sync failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[auto-scan] synced %d items", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
doScan()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
doScan()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSyncItems converts cached library items to sync request items.
|
||||||
|
func buildSyncItems(cache *library.LibraryCache) []agent.LibrarySyncItem {
|
||||||
|
items := make([]agent.LibrarySyncItem, 0, len(cache.Items))
|
||||||
|
for _, item := range cache.Items {
|
||||||
|
if item.ScanError != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
si := agent.LibrarySyncItem{
|
||||||
|
FilePath: item.FilePath,
|
||||||
|
FileName: item.FileName,
|
||||||
|
FileSize: item.FileSize,
|
||||||
|
Title: item.Title,
|
||||||
|
Year: item.Year,
|
||||||
|
ContentType: library.DeriveContentType(item),
|
||||||
|
Season: item.Season,
|
||||||
|
Episode: item.Episode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.MediaInfo != nil {
|
||||||
|
if item.MediaInfo.Video != nil {
|
||||||
|
si.Resolution = library.ResolveResolution(item.MediaInfo.Video.Height)
|
||||||
|
si.VideoCodec = item.MediaInfo.Video.Codec
|
||||||
|
si.HDR = item.MediaInfo.Video.HDR
|
||||||
|
si.BitDepth = item.MediaInfo.Video.BitDepth
|
||||||
|
}
|
||||||
|
codec, channels := library.PrimaryAudioTrack(item.MediaInfo.Audio)
|
||||||
|
si.AudioCodec = codec
|
||||||
|
si.AudioChannels = channels
|
||||||
|
si.AudioLanguages = library.AudioLanguages(item.MediaInfo.Audio)
|
||||||
|
si.SubtitleLanguages = library.SubtitleLanguages(item.MediaInfo.Subtitles)
|
||||||
|
si.AudioTracks = item.MediaInfo.Audio
|
||||||
|
si.SubtitleTracks = item.MediaInfo.Subtitles
|
||||||
|
si.VideoInfo = item.MediaInfo.Video
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, si)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,12 @@ type GeneralConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryConfig struct {
|
type LibraryConfig struct {
|
||||||
ScanPath string `toml:"scan_path"` // remembered from last scan
|
ScanPath string `toml:"scan_path"` // remembered from last scan
|
||||||
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
||||||
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
||||||
BackupDir string `toml:"backup_dir"` // for replaced files
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default returns a Config with sensible defaults.
|
// Default returns a Config with sensible defaults.
|
||||||
|
|
@ -98,6 +100,11 @@ func Default() Config {
|
||||||
Country: "US",
|
Country: "US",
|
||||||
Locale: "en",
|
Locale: "en",
|
||||||
},
|
},
|
||||||
|
Library: LibraryConfig{
|
||||||
|
AutoScan: true,
|
||||||
|
ScanInterval: "24h",
|
||||||
|
Workers: 8,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,17 @@ func (m *Manager) Submit(ctx context.Context, at agent.Task) {
|
||||||
|
|
||||||
m.reporter.Track(task)
|
m.reporter.Track(task)
|
||||||
|
|
||||||
|
// Force start: bypass semaphore (like Transmission's "Force Start")
|
||||||
|
if at.ForceStart {
|
||||||
|
log.Printf("[%s] force start: bypassing queue", task.ID[:8])
|
||||||
|
m.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer m.wg.Done()
|
||||||
|
m.processTask(ctx, task)
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire semaphore slot
|
// Acquire semaphore slot
|
||||||
select {
|
select {
|
||||||
case m.sem <- struct{}{}:
|
case m.sem <- struct{}{}:
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,11 @@ type TorrentDownloader struct {
|
||||||
|
|
||||||
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
|
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
|
||||||
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
||||||
// 0 = unlimited for all timeouts (like qBittorrent)
|
// MetadataTimeout: 0 = unlimited (wait forever like qBittorrent)
|
||||||
// Users can set these in config.toml [downloads] section
|
// StallTimeout: default 30m (no bytes for 30 min = dead torrent, frees the slot)
|
||||||
|
if cfg.StallTimeout == 0 {
|
||||||
|
cfg.StallTimeout = 30 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("create data dir: %w", err)
|
return nil, fmt.Errorf("create data dir: %w", err)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue