Files
ncore-hnr/internal/app/app.go
Zsolt Alföldi 469e5b0678 init
2026-05-07 00:14:02 +02:00

303 lines
7.2 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"
)
func Run(cfg config.Config) (model.RunSummary, error) {
startedAt := time.Now().UTC()
summary := model.RunSummary{StartedAt: startedAt, DryRun: cfg.DryRun}
db, err := store.Open(cfg.DBPath)
if err != nil {
return summary, err
}
defer db.Close()
ncoreClient := ncore.New(newHTTPClient(cfg.HTTPTimeout), cfg.NCoreLoginURL, cfg.NCoreHitRunURL)
if err := ncoreClient.Login(cfg.NCoreUsername, cfg.NCorePassword); err != nil {
return summary, err
}
page, err := ncoreClient.FetchHitRun()
if err != nil {
return summary, err
}
summary.TotalRisk = len(page.Torrents)
activeIDs := map[int64]bool{}
for _, torrent := range page.Torrents {
activeIDs[torrent.ID] = true
}
if err := db.MarkResolved(activeIDs, startedAt); err != nil {
return summary, err
}
var qbitClient *qbit.Client
var qbitTorrents []model.QBitTorrent
if !cfg.SkipQBit && cfg.QBitURL != "" {
qbitClient = qbit.New(cfg.QBitURL, newHTTPClient(cfg.HTTPTimeout))
if err := qbitClient.Login(cfg.QBitUsername, cfg.QBitPassword); err != nil {
return summary, err
}
qbitTorrents, err = qbitClient.Torrents()
if err != nil {
return summary, err
}
}
for _, torrent := range page.Torrents {
state, err := db.UpsertSeen(torrent, startedAt)
if err != nil {
return summary, 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 := qbitClient.ForceStart(matched.Hash); err != nil {
return summary, fmt.Errorf("force-start %q: %w", torrent.Name, err)
}
result.ForceStarted = true
summary.ForceStarted++
if err := qbitClient.Reannounce(matched.Hash); err != nil {
return summary, fmt.Errorf("reannounce %q: %w", torrent.Name, err)
}
result.Reannounced = true
summary.Reannounced++
}
if err := db.RecordQBit(torrent, matched, startedAt, result.ForceStarted, result.Reannounced); err != nil {
return summary, err
}
if startedAt.Sub(state.FirstSeenAt) >= cfg.AlertAfter {
result.ManualNeeded = true
summary.ManualNeeded++
if err := db.MarkManualNeeded(torrent.ID, startedAt); err != nil {
return summary, err
}
}
summary.Results = append(summary.Results, result)
}
if err := db.InsertRun(summary); err != nil {
return summary, err
}
notifier := newNotifier(cfg)
if notifier != nil {
if err := 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
fmt.Printf("Last run: %s | total=%d matched=%d unmatched=%d force-started=%d reannounced=%d manual-needed=%d dry-run=%t\n",
run.StartedAt,
run.TotalRisk,
run.Matched,
run.Unmatched,
run.ForceStarted,
run.Reannounced,
run.ManualNeeded,
run.DryRun,
)
}
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
}
}