package app import ( "encoding/json" "fmt" "net/http" "net/http/cookiejar" "os" "time" "ncore-hnr/internal/config" "ncore-hnr/internal/model" "ncore-hnr/internal/ncore" "ncore-hnr/internal/notify" "ncore-hnr/internal/qbit" "ncore-hnr/internal/store" ) const retryAttempts = 3 const retryDelay = 20 * time.Second func Run(cfg config.Config) (model.RunSummary, error) { startedAt := time.Now().UTC() summary := model.RunSummary{StartedAt: startedAt, Status: "success", DryRun: cfg.DryRun} db, err := store.Open(cfg.DBPath) if err != nil { return failRun(cfg, nil, summary, "open database", err) } defer db.Close() var page model.HitRunPage if err := withRetry("fetch nCore HnR page", func() error { ncoreClient := ncore.New(newHTTPClient(cfg.HTTPTimeout), cfg.NCoreLoginURL, cfg.NCoreHitRunURL) if err := ncoreClient.Login(cfg.NCoreUsername, cfg.NCorePassword); err != nil { return fmt.Errorf("login: %w", err) } fetched, err := ncoreClient.FetchHitRun() if err != nil { return fmt.Errorf("fetch: %w", err) } page = fetched return nil }); err != nil { return failRun(cfg, db, summary, "fetch nCore HnR page", err) } summary.TotalRisk = len(page.Torrents) var qbitClient *qbit.Client var qbitTorrents []model.QBitTorrent if !cfg.SkipQBit && cfg.QBitURL != "" { if err := withRetry("load qBittorrent torrents", func() error { client := qbit.New(cfg.QBitURL, newHTTPClient(cfg.HTTPTimeout)) if err := client.Login(cfg.QBitUsername, cfg.QBitPassword); err != nil { return fmt.Errorf("login: %w", err) } torrents, err := client.Torrents() if err != nil { return fmt.Errorf("list torrents: %w", err) } qbitClient = client qbitTorrents = torrents return nil }); err != nil { return failRun(cfg, db, summary, "load qBittorrent torrents", err) } } activeIDs := map[int64]bool{} for _, torrent := range page.Torrents { activeIDs[torrent.ID] = true } if err := db.MarkResolved(activeIDs, startedAt); err != nil { return failRun(cfg, db, summary, "mark resolved torrents", err) } for _, torrent := range page.Torrents { state, err := db.UpsertSeen(torrent, startedAt) if err != nil { return failRun(cfg, db, summary, "upsert torrent state", err) } result := model.ActionResult{ Torrent: torrent, FirstSeenAt: state.FirstSeenAt, LastSeenAt: startedAt, } if qbitClient == nil { result.Message = "qBittorrent skipped" summary.Unmatched++ summary.Results = append(summary.Results, result) continue } matched, ok := qbit.MatchByName(torrent.Name, qbitTorrents) if !ok { result.Message = "not found in qBittorrent" summary.Unmatched++ summary.Results = append(summary.Results, result) continue } result.Matched = true result.QBit = &matched summary.Matched++ if !cfg.DryRun { if err := withRetry(fmt.Sprintf("force-start %q", torrent.Name), func() error { return qbitClient.ForceStart(matched.Hash) }); err != nil { return failRun(cfg, db, summary, fmt.Sprintf("force-start %q", torrent.Name), err) } result.ForceStarted = true summary.ForceStarted++ if err := withRetry(fmt.Sprintf("reannounce %q", torrent.Name), func() error { return qbitClient.Reannounce(matched.Hash) }); err != nil { return failRun(cfg, db, summary, fmt.Sprintf("reannounce %q", torrent.Name), err) } result.Reannounced = true summary.Reannounced++ } if err := db.RecordQBit(torrent, matched, startedAt, result.ForceStarted, result.Reannounced); err != nil { return failRun(cfg, db, summary, "record qBittorrent state", err) } if startedAt.Sub(state.FirstSeenAt) >= cfg.AlertAfter { result.ManualNeeded = true summary.ManualNeeded++ if err := db.MarkManualNeeded(torrent.ID, startedAt); err != nil { return failRun(cfg, db, summary, "mark manual-needed torrent", err) } } summary.Results = append(summary.Results, result) } if err := db.InsertRun(summary); err != nil { return failRun(cfg, db, summary, "record run", err) } if cfg.NotificationDryRun { printNotificationPreview(cfg.NotificationType, summary.Results) return summary, nil } notifier := newNotifier(cfg) if notifier != nil { if err := withRetry("send manual-needed notification", func() error { return notifier.SendManualNeeded(summary.Results) }); err != nil { return summary, err } } return summary, nil } func PrintSummary(summary model.RunSummary, asJSON bool) error { if asJSON { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(summary) } fmt.Printf("nCore HnR check finished at %s\n", summary.StartedAt.Format(time.RFC3339)) fmt.Printf("dry-run=%t total=%d matched=%d unmatched=%d force-started=%d reannounced=%d manual-needed=%d\n\n", summary.DryRun, summary.TotalRisk, summary.Matched, summary.Unmatched, summary.ForceStarted, summary.Reannounced, summary.ManualNeeded, ) for _, result := range summary.Results { status := "unmatched" if result.Matched { status = "matched" } if result.ManualNeeded { status = "manual-needed" } fmt.Printf("[%s] %s", status, result.Torrent.Name) if result.QBit != nil { fmt.Printf(" | qbit=%s %.1f%% ratio=%.3f", result.QBit.State, result.QBit.Progress*100, result.QBit.Ratio) } if result.Message != "" { fmt.Printf(" | %s", result.Message) } fmt.Println() } return nil } func LoadStats(dbPath string) (model.StatsSnapshot, error) { db, err := store.Open(dbPath) if err != nil { return model.StatsSnapshot{}, err } defer db.Close() return db.Stats() } func LoadStatus(dbPath string) (model.StatusSnapshot, error) { db, err := store.Open(dbPath) if err != nil { return model.StatusSnapshot{}, err } defer db.Close() return db.Status() } func PrintStats(snapshot model.StatsSnapshot, asJSON bool) error { if asJSON { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(snapshot) } fmt.Println("Torrent statuses:") if len(snapshot.Counts) == 0 { fmt.Println(" no tracked torrents") } else { for _, count := range snapshot.Counts { fmt.Printf(" %-14s %d\n", count.Status+":", count.Count) } } fmt.Println() if snapshot.LastRun == nil { fmt.Println("Last run: none") } else { run := snapshot.LastRun status := defaultText(run.Status, "success") fmt.Printf("Last run: %s | status=%s total=%d matched=%d unmatched=%d force-started=%d reannounced=%d manual-needed=%d dry-run=%t\n", run.StartedAt, status, run.TotalRisk, run.Matched, run.Unmatched, run.ForceStarted, run.Reannounced, run.ManualNeeded, run.DryRun, ) if status == "failed" { fmt.Printf("Last error: %s | %s\n", defaultText(run.ErrorStep, "-"), defaultText(run.ErrorMessage, "-")) } } if len(snapshot.ManualNeeded) > 0 { fmt.Println() fmt.Println("Manual needed:") for _, torrent := range snapshot.ManualNeeded { fmt.Printf(" %d %s first_seen=%s qbit=%s\n", torrent.ID, torrent.Name, torrent.FirstSeenAt, qbitSummary(torrent)) } } return nil } func PrintStatus(snapshot model.StatusSnapshot, asJSON bool) error { if asJSON { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") return encoder.Encode(snapshot) } if len(snapshot.Torrents) == 0 { fmt.Println("No tracked torrents.") return nil } fmt.Printf("%-14s %-7s %-5s %-7s %-6s %-19s %s\n", "STATUS", "QBIT", "PROG", "RATIO", "HNR", "FIRST SEEN", "NAME") for _, torrent := range snapshot.Torrents { fmt.Printf("%-14s %-7s %-5.1f %-7.3f %-6t %-19s %s\n", torrent.Status, defaultText(torrent.QBitState, "-"), torrent.QBitProgress*100, torrent.QBitRatio, torrent.HnRMarked, torrent.FirstSeenAt, torrent.Name, ) } return nil } func qbitSummary(torrent model.TorrentStatus) string { if torrent.QBitState == "" { return "-" } return fmt.Sprintf("%s %.1f%% ratio=%.3f", torrent.QBitState, torrent.QBitProgress*100, torrent.QBitRatio) } func defaultText(value string, fallback string) string { if value == "" { return fallback } return value } func newHTTPClient(timeout time.Duration) *http.Client { jar, _ := cookiejar.New(nil) return &http.Client{Jar: jar, Timeout: timeout} } func newNotifier(cfg config.Config) notify.Sender { switch cfg.NotificationType { case "ntfy": return notify.NotificationNTFY{ URL: cfg.NotificationNTFYURL, HTTPClient: newHTTPClient(cfg.HTTPTimeout), } case "smtp": return notify.NotificationSMTP{ Host: cfg.NotificationSMTPHost, Port: cfg.NotificationSMTPPort, Username: cfg.NotificationSMTPUsername, Password: cfg.NotificationSMTPPassword, From: cfg.NotificationSMTPFrom, To: cfg.NotificationSMTPTo, } default: return nil } } func printNotificationPreview(notificationType string, results []model.ActionResult) { subject, body, ok := notify.ManualNeededMessage(results) if !ok { fmt.Fprintln(os.Stderr, "notification dry-run: no manual-needed torrents, nothing would be sent") return } if notificationType == "" { notificationType = "unconfigured" } fmt.Fprintf(os.Stderr, "notification dry-run: type=%s\n", notificationType) fmt.Fprintf(os.Stderr, "Subject: %s\n\n%s", subject, body) } func withRetry(step string, fn func() error) error { var lastErr error for attempt := 1; attempt <= retryAttempts; attempt++ { if err := fn(); err != nil { lastErr = err fmt.Fprintf(os.Stderr, "%s failed (attempt %d/%d): %v\n", step, attempt, retryAttempts, err) if attempt < retryAttempts { time.Sleep(retryDelay) } continue } return nil } return fmt.Errorf("failed after %d attempts: %w", retryAttempts, lastErr) } func failRun(cfg config.Config, db *store.Store, summary model.RunSummary, step string, err error) (model.RunSummary, error) { summary.Status = "failed" summary.ErrorStep = step summary.ErrorMessage = err.Error() fmt.Fprintf(os.Stderr, "run failed at %s: %v\n", step, err) if db != nil { if insertErr := db.InsertRun(summary); insertErr != nil { fmt.Fprintf(os.Stderr, "record failed run: %v\n", insertErr) } } if notifyErr := sendFailureNotification(cfg, step, err); notifyErr != nil { return summary, fmt.Errorf("%s: %w; failure notification failed: %v", step, err, notifyErr) } return summary, fmt.Errorf("%s: %w", step, err) } func sendFailureNotification(cfg config.Config, step string, err error) error { if cfg.NotificationDryRun { subject, body := notify.FailureMessage(step, err) notificationType := cfg.NotificationType if notificationType == "" { notificationType = "unconfigured" } fmt.Fprintf(os.Stderr, "failure notification dry-run: type=%s\n", notificationType) fmt.Fprintf(os.Stderr, "Subject: %s\n\n%s", subject, body) return nil } notifier := newNotifier(cfg) if notifier == nil { return nil } return withRetry("send failure notification", func() error { return notifier.SendFailure(step, err) }) }