diff --git a/internal/agent/types.go b/internal/agent/types.go index 25990cf..f95cdd1 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -70,6 +70,7 @@ type Task struct { NzbPassword string `json:"nzbPassword,omitempty"` // Password for encrypted NZB archives ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode) 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. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 311e209..f7e5de4 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -16,6 +16,7 @@ import ( "github.com/torrentclaw/torrentclaw-cli/internal/agent" "github.com/torrentclaw/torrentclaw-cli/internal/config" "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/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) errCh := make(chan error, 1) go func() { @@ -511,3 +523,130 @@ func formatSpeedLog(bps int64) string { 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 +} diff --git a/internal/config/config.go b/internal/config/config.go index f8d42fd..04195b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,10 +68,12 @@ type GeneralConfig struct { } type LibraryConfig struct { - ScanPath string `toml:"scan_path"` // remembered from last scan - Workers int `toml:"workers"` // concurrent ffprobe (default 8) - FFprobePath string `toml:"ffprobe_path"` // optional explicit path - BackupDir string `toml:"backup_dir"` // for replaced files + ScanPath string `toml:"scan_path"` // remembered from last scan + Workers int `toml:"workers"` // concurrent ffprobe (default 8) + FFprobePath string `toml:"ffprobe_path"` // optional explicit path + 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. @@ -98,6 +100,11 @@ func Default() Config { Country: "US", Locale: "en", }, + Library: LibraryConfig{ + AutoScan: true, + ScanInterval: "24h", + Workers: 8, + }, } } diff --git a/internal/engine/manager.go b/internal/engine/manager.go index e9a1c7a..0367a8f 100644 --- a/internal/engine/manager.go +++ b/internal/engine/manager.go @@ -59,6 +59,17 @@ func (m *Manager) Submit(ctx context.Context, at agent.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 select { case m.sem <- struct{}{}: diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go index dfca1f0..5bcc561 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -82,8 +82,11 @@ type TorrentDownloader struct { // NewTorrentDownloader creates a BitTorrent downloader with a long-lived client. func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { - // 0 = unlimited for all timeouts (like qBittorrent) - // Users can set these in config.toml [downloads] section + // MetadataTimeout: 0 = unlimited (wait forever like qBittorrent) + // 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 { return nil, fmt.Errorf("create data dir: %w", err)