- Implemented a retry mechanism for external calls, allowing up to 3 attempts before failing. - Enhanced error handling to send failure notifications when setup steps fail, including detailed error messages. - Updated RunSummary model to include status, error step, and error message fields for better tracking of run outcomes. - Modified database schema to store failure metadata for runs. - Updated README.md to reflect changes in error handling and notification behavior.
404 lines
11 KiB
Go
404 lines
11 KiB
Go
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)
|
|
})
|
|
}
|