init
This commit is contained in:
302
internal/app/app.go
Normal file
302
internal/app/app.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
209
internal/config/config.go
Normal file
209
internal/config/config.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NCoreUsername string
|
||||
NCorePassword string
|
||||
NCoreLoginURL string
|
||||
NCoreHitRunURL string
|
||||
|
||||
QBitURL string
|
||||
QBitUsername string
|
||||
QBitPassword string
|
||||
SkipQBit bool
|
||||
|
||||
DBPath string
|
||||
DryRun bool
|
||||
JSONOutput bool
|
||||
AlertAfter time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
|
||||
NotificationType string
|
||||
NotificationNTFYURL string
|
||||
NotificationSMTPHost string
|
||||
NotificationSMTPPort string
|
||||
NotificationSMTPUsername string
|
||||
NotificationSMTPPassword string
|
||||
NotificationSMTPFrom string
|
||||
NotificationSMTPTo string
|
||||
}
|
||||
|
||||
func Load(args []string) (Config, error) {
|
||||
loadDotenv(".env", "../.env")
|
||||
|
||||
cfg := Config{
|
||||
NCoreUsername: env("NCORE_USERNAME", ""),
|
||||
NCorePassword: env("NCORE_PASSWORD", ""),
|
||||
NCoreLoginURL: env("NCORE_LOGIN_URL", "https://ncore.pro/login.php"),
|
||||
NCoreHitRunURL: env("NCORE_HITRUN_URL", "https://ncore.pro/hitnrun.php"),
|
||||
QBitURL: env("QBITTORRENT_URL", ""),
|
||||
QBitUsername: env("QBITTORRENT_USERNAME", ""),
|
||||
QBitPassword: env("QBITTORRENT_PASSWORD", ""),
|
||||
SkipQBit: envBool("SKIP_QBIT", false),
|
||||
DBPath: env("APP_DB_PATH", "data/ncore-hnr.sqlite"),
|
||||
DryRun: envBool("DRY_RUN", false),
|
||||
JSONOutput: envBool("JSON_OUTPUT", false),
|
||||
AlertAfter: envDuration("ALERT_AFTER", 48*time.Hour),
|
||||
HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second),
|
||||
|
||||
NotificationType: env("NOTIFICATION_TYPE", ""),
|
||||
NotificationNTFYURL: env("NOTIFICATION_NTFY_URL", env("NOTIFY_URL", "")),
|
||||
NotificationSMTPHost: env("NOTIFICATION_SMTP_HOST", ""),
|
||||
NotificationSMTPPort: env("NOTIFICATION_SMTP_PORT", "587"),
|
||||
NotificationSMTPUsername: env("NOTIFICATION_SMTP_USERNAME", ""),
|
||||
NotificationSMTPPassword: env("NOTIFICATION_SMTP_PASSWORD", ""),
|
||||
NotificationSMTPFrom: env("NOTIFICATION_SMTP_FROM", ""),
|
||||
NotificationSMTPTo: env("NOTIFICATION_SMTP_TO", ""),
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("ncore-hnr", flag.ContinueOnError)
|
||||
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||
fs.BoolVar(&cfg.DryRun, "dry-run", cfg.DryRun, "print intended qBittorrent actions without executing them")
|
||||
fs.BoolVar(&cfg.SkipQBit, "skip-qbit", cfg.SkipQBit, "skip qBittorrent matching/actions")
|
||||
fs.BoolVar(&cfg.JSONOutput, "json", cfg.JSONOutput, "print JSON summary")
|
||||
fs.DurationVar(&cfg.AlertAfter, "alert-after", cfg.AlertAfter, "mark active torrents manual-needed after this duration")
|
||||
fs.StringVar(&cfg.NotificationType, "notification-type", cfg.NotificationType, "notification sender: ntfy, smtp, or empty")
|
||||
fs.StringVar(&cfg.NotificationNTFYURL, "notification-ntfy-url", cfg.NotificationNTFYURL, "ntfy topic URL for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationNTFYURL, "notify-url", cfg.NotificationNTFYURL, "deprecated: use --notification-ntfy-url")
|
||||
fs.StringVar(&cfg.NotificationSMTPHost, "notification-smtp-host", cfg.NotificationSMTPHost, "SMTP host for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationSMTPPort, "notification-smtp-port", cfg.NotificationSMTPPort, "SMTP port for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationSMTPUsername, "notification-smtp-username", cfg.NotificationSMTPUsername, "SMTP username for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationSMTPPassword, "notification-smtp-password", cfg.NotificationSMTPPassword, "SMTP password for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationSMTPFrom, "notification-smtp-from", cfg.NotificationSMTPFrom, "SMTP From header for manual-needed alerts")
|
||||
fs.StringVar(&cfg.NotificationSMTPTo, "notification-smtp-to", cfg.NotificationSMTPTo, "SMTP To header for manual-needed alerts")
|
||||
fs.DurationVar(&cfg.HTTPTimeout, "http-timeout", cfg.HTTPTimeout, "HTTP timeout")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
cfg.NotificationType = normalizeNotificationType(cfg)
|
||||
|
||||
if cfg.NCoreUsername == "" {
|
||||
return cfg, fmt.Errorf("missing NCORE_USERNAME")
|
||||
}
|
||||
if cfg.NCorePassword == "" {
|
||||
return cfg, fmt.Errorf("missing NCORE_PASSWORD")
|
||||
}
|
||||
if !cfg.SkipQBit && !cfg.DryRun {
|
||||
if cfg.QBitURL == "" || cfg.QBitUsername == "" || cfg.QBitPassword == "" {
|
||||
return cfg, fmt.Errorf("set QBITTORRENT_URL, QBITTORRENT_USERNAME and QBITTORRENT_PASSWORD, or use --dry-run/--skip-qbit")
|
||||
}
|
||||
}
|
||||
if err := validateNotification(cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadReadOnly(args []string) (Config, error) {
|
||||
loadDotenv(".env", "../.env")
|
||||
|
||||
cfg := Config{
|
||||
DBPath: env("APP_DB_PATH", "data/ncore-hnr.sqlite"),
|
||||
JSONOutput: envBool("JSON_OUTPUT", false),
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("ncore-hnr", flag.ContinueOnError)
|
||||
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||
fs.BoolVar(&cfg.JSONOutput, "json", cfg.JSONOutput, "print JSON output")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadDotenv(paths ...string) {
|
||||
for _, path := range paths {
|
||||
_ = godotenv.Load(path)
|
||||
}
|
||||
}
|
||||
|
||||
func env(name string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func normalizeNotificationType(cfg Config) string {
|
||||
notificationType := strings.ToLower(strings.TrimSpace(cfg.NotificationType))
|
||||
switch notificationType {
|
||||
case "none", "off", "disabled":
|
||||
return ""
|
||||
case "":
|
||||
if strings.TrimSpace(cfg.NotificationNTFYURL) != "" {
|
||||
return "ntfy"
|
||||
}
|
||||
if strings.TrimSpace(cfg.NotificationSMTPHost) != "" || strings.TrimSpace(cfg.NotificationSMTPTo) != "" {
|
||||
return "smtp"
|
||||
}
|
||||
}
|
||||
return notificationType
|
||||
}
|
||||
|
||||
func validateNotification(cfg Config) error {
|
||||
switch cfg.NotificationType {
|
||||
case "":
|
||||
return nil
|
||||
case "ntfy":
|
||||
if strings.TrimSpace(cfg.NotificationNTFYURL) == "" {
|
||||
return fmt.Errorf("missing NOTIFICATION_NTFY_URL for ntfy notifications")
|
||||
}
|
||||
case "smtp":
|
||||
if strings.TrimSpace(cfg.NotificationSMTPHost) == "" {
|
||||
return fmt.Errorf("missing NOTIFICATION_SMTP_HOST for smtp notifications")
|
||||
}
|
||||
if strings.TrimSpace(cfg.NotificationSMTPPort) == "" {
|
||||
return fmt.Errorf("missing NOTIFICATION_SMTP_PORT for smtp notifications")
|
||||
}
|
||||
if strings.TrimSpace(cfg.NotificationSMTPFrom) == "" {
|
||||
return fmt.Errorf("missing NOTIFICATION_SMTP_FROM for smtp notifications")
|
||||
}
|
||||
if strings.TrimSpace(cfg.NotificationSMTPTo) == "" {
|
||||
return fmt.Errorf("missing NOTIFICATION_SMTP_TO for smtp notifications")
|
||||
}
|
||||
if (strings.TrimSpace(cfg.NotificationSMTPUsername) == "") != (strings.TrimSpace(cfg.NotificationSMTPPassword) == "") {
|
||||
return fmt.Errorf("set both NOTIFICATION_SMTP_USERNAME and NOTIFICATION_SMTP_PASSWORD, or leave both empty")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported NOTIFICATION_TYPE %q", cfg.NotificationType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
101
internal/model/model.go
Normal file
101
internal/model/model.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Torrent struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Start string `json:"start"`
|
||||
Updated string `json:"updated"`
|
||||
Status string `json:"status"`
|
||||
Uploaded string `json:"uploaded"`
|
||||
Downloaded string `json:"downloaded"`
|
||||
Remaining string `json:"remaining"`
|
||||
Ratio string `json:"ratio"`
|
||||
HnRMarked bool `json:"hnr_marked"`
|
||||
QBit *QBitTorrent `json:"qbit,omitempty"`
|
||||
}
|
||||
|
||||
type HitRunPage struct {
|
||||
Alert string `json:"alert,omitempty"`
|
||||
Stats map[string]string `json:"stats"`
|
||||
Torrents []Torrent `json:"torrents"`
|
||||
}
|
||||
|
||||
type QBitTorrent struct {
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Progress float64 `json:"progress"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
}
|
||||
|
||||
type ActionResult struct {
|
||||
Torrent Torrent `json:"torrent"`
|
||||
Matched bool `json:"matched"`
|
||||
ForceStarted bool `json:"force_started"`
|
||||
Reannounced bool `json:"reannounced"`
|
||||
ManualNeeded bool `json:"manual_needed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
QBit *QBitTorrent `json:"qbit,omitempty"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type RunSummary struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
TotalRisk int `json:"total_risk"`
|
||||
Matched int `json:"matched"`
|
||||
Unmatched int `json:"unmatched"`
|
||||
ForceStarted int `json:"force_started"`
|
||||
Reannounced int `json:"reannounced"`
|
||||
ManualNeeded int `json:"manual_needed"`
|
||||
Results []ActionResult `json:"results"`
|
||||
}
|
||||
|
||||
type StatusCount struct {
|
||||
Status string `json:"status"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type RunRecord struct {
|
||||
StartedAt string `json:"started_at"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
TotalRisk int `json:"total_risk"`
|
||||
Matched int `json:"matched"`
|
||||
Unmatched int `json:"unmatched"`
|
||||
ForceStarted int `json:"force_started"`
|
||||
Reannounced int `json:"reannounced"`
|
||||
ManualNeeded int `json:"manual_needed"`
|
||||
}
|
||||
|
||||
type TorrentStatus struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
LastResolvedAt string `json:"last_resolved_at,omitempty"`
|
||||
HnRMarked bool `json:"hnr_marked"`
|
||||
QBitName string `json:"qbit_name,omitempty"`
|
||||
QBitState string `json:"qbit_state,omitempty"`
|
||||
QBitProgress float64 `json:"qbit_progress,omitempty"`
|
||||
QBitRatio float64 `json:"qbit_ratio,omitempty"`
|
||||
LastActionAt string `json:"last_action_at,omitempty"`
|
||||
ManualNeededAt string `json:"manual_needed_at,omitempty"`
|
||||
}
|
||||
|
||||
type StatsSnapshot struct {
|
||||
Counts []StatusCount `json:"counts"`
|
||||
LastRun *RunRecord `json:"last_run,omitempty"`
|
||||
ManualNeeded []TorrentStatus `json:"manual_needed"`
|
||||
}
|
||||
|
||||
type StatusSnapshot struct {
|
||||
Torrents []TorrentStatus `json:"torrents"`
|
||||
}
|
||||
231
internal/ncore/client.go
Normal file
231
internal/ncore/client.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package ncore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"ncore-hnr/internal/model"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
loginURL string
|
||||
hitRunURL string
|
||||
}
|
||||
|
||||
func New(httpClient *http.Client, loginURL string, hitRunURL string) *Client {
|
||||
if httpClient == nil {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
httpClient = &http.Client{Jar: jar}
|
||||
}
|
||||
return &Client{httpClient: httpClient, loginURL: loginURL, hitRunURL: hitRunURL}
|
||||
}
|
||||
|
||||
func (c *Client) Login(username string, password string) error {
|
||||
resp, err := c.httpClient.Get(c.loginURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("login page returned %s", resp.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := findLoginForm(doc)
|
||||
if form.Length() == 0 {
|
||||
return fmt.Errorf("could not find nCore login form")
|
||||
}
|
||||
|
||||
values := formValues(form)
|
||||
values.Set(inputName(form, "text", "nev"), username)
|
||||
values.Set(inputName(form, "password", "pass"), password)
|
||||
if values.Get("submitted") == "" {
|
||||
values.Set("submitted", "1")
|
||||
}
|
||||
if values.Get("set_lang") == "" {
|
||||
values.Set("set_lang", "hu")
|
||||
}
|
||||
values.Set("ne_leptessen_ki", "1")
|
||||
|
||||
action, ok := form.Attr("action")
|
||||
if !ok || strings.TrimSpace(action) == "" {
|
||||
action = c.loginURL
|
||||
}
|
||||
target, err := url.Parse(c.loginURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionURL, err := target.Parse(action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
postResp, err := c.httpClient.PostForm(actionURL.String(), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
if postResp.StatusCode < 200 || postResp.StatusCode >= 300 {
|
||||
return fmt.Errorf("login returned %s", postResp.Status)
|
||||
}
|
||||
|
||||
loginDoc, err := goquery.NewDocumentFromReader(postResp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if findLoginForm(loginDoc).Length() > 0 {
|
||||
return fmt.Errorf("login failed; check credentials or browser-only checks")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchHitRun() (model.HitRunPage, error) {
|
||||
reqURL, err := url.Parse(c.hitRunURL)
|
||||
if err != nil {
|
||||
return model.HitRunPage{}, err
|
||||
}
|
||||
query := reqURL.Query()
|
||||
query.Set("showall", "false")
|
||||
reqURL.RawQuery = query.Encode()
|
||||
|
||||
resp, err := c.httpClient.Get(reqURL.String())
|
||||
if err != nil {
|
||||
return model.HitRunPage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return model.HitRunPage{}, fmt.Errorf("hitnrun page returned %s", resp.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return model.HitRunPage{}, err
|
||||
}
|
||||
if findLoginForm(doc).Length() > 0 {
|
||||
return model.HitRunPage{}, fmt.Errorf("nCore session is not authenticated")
|
||||
}
|
||||
|
||||
return parseHitRunPage(doc), nil
|
||||
}
|
||||
|
||||
func parseHitRunPage(doc *goquery.Document) model.HitRunPage {
|
||||
page := model.HitRunPage{Stats: map[string]string{}}
|
||||
|
||||
alert := cleanText(doc.Find("#hnrAlert .fobox_tartalom").Clone().ChildrenFiltered("script").Remove().End())
|
||||
page.Alert = alert
|
||||
|
||||
doc.Find(".dt").Each(func(_ int, label *goquery.Selection) {
|
||||
value := label.NextFiltered(".dd")
|
||||
key := strings.TrimSuffix(cleanText(label), ":")
|
||||
val := cleanText(value)
|
||||
if key != "" && val != "" {
|
||||
page.Stats[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find(".hnr_torrents > div").Each(func(_ int, row *goquery.Selection) {
|
||||
link := row.Find(".hnr_tname a").First()
|
||||
href, _ := link.Attr("href")
|
||||
title, _ := link.Attr("title")
|
||||
if title == "" {
|
||||
title = cleanText(row.Find(".hnr_tname"))
|
||||
}
|
||||
|
||||
page.Torrents = append(page.Torrents, model.Torrent{
|
||||
ID: torrentID(href),
|
||||
Name: title,
|
||||
URL: absoluteURL(href),
|
||||
Start: cleanText(row.Find(".hnr_tstart")),
|
||||
Updated: cleanText(row.Find(".hnr_tlastactive")),
|
||||
Status: cleanText(row.Find(".hnr_tseed")),
|
||||
Uploaded: cleanText(row.Find(".hnr_tup")),
|
||||
Downloaded: cleanText(row.Find(".hnr_tdown")),
|
||||
Remaining: cleanText(row.Find(".hnr_ttimespent")),
|
||||
Ratio: cleanText(row.Find(".hnr_tratio")),
|
||||
HnRMarked: row.Find(".hnr_tstart .stopped").Length() > 0,
|
||||
})
|
||||
})
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func findLoginForm(doc *goquery.Document) *goquery.Selection {
|
||||
return doc.Find("form").FilterFunction(func(_ int, form *goquery.Selection) bool {
|
||||
return form.Find("input[type='password']").Length() > 0
|
||||
}).First()
|
||||
}
|
||||
|
||||
func formValues(form *goquery.Selection) url.Values {
|
||||
values := url.Values{}
|
||||
form.Find("input").Each(func(_ int, input *goquery.Selection) {
|
||||
name, ok := input.Attr("name")
|
||||
if !ok || name == "" {
|
||||
return
|
||||
}
|
||||
inputType := strings.ToLower(attr(input, "type", "text"))
|
||||
if inputType == "submit" || inputType == "button" || inputType == "image" || inputType == "file" || inputType == "text" || inputType == "password" {
|
||||
return
|
||||
}
|
||||
if (inputType == "checkbox" || inputType == "radio") && attr(input, "checked", "") == "" {
|
||||
return
|
||||
}
|
||||
values.Set(name, attr(input, "value", ""))
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
func inputName(form *goquery.Selection, inputType string, preferred string) string {
|
||||
if form.Find("input[name='"+preferred+"']").Length() > 0 {
|
||||
return preferred
|
||||
}
|
||||
if name, ok := form.Find("input[type='" + inputType + "']").First().Attr("name"); ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
func torrentID(href string) int64 {
|
||||
parsed, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseInt(parsed.Query().Get("id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func absoluteURL(href string) string {
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
return href
|
||||
}
|
||||
return "https://ncore.pro/" + strings.TrimPrefix(href, "/")
|
||||
}
|
||||
|
||||
func cleanText(selection *goquery.Selection) string {
|
||||
return strings.Join(strings.Fields(selection.Text()), " ")
|
||||
}
|
||||
|
||||
func attr(selection *goquery.Selection, name string, fallback string) string {
|
||||
value, ok := selection.Attr(name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
131
internal/notify/notify.go
Normal file
131
internal/notify/notify.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ncore-hnr/internal/model"
|
||||
)
|
||||
|
||||
const manualNeededSubject = "nCore HnR manual work"
|
||||
|
||||
type Sender interface {
|
||||
SendManualNeeded(results []model.ActionResult) error
|
||||
}
|
||||
|
||||
type NotificationNTFY struct {
|
||||
URL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func (n NotificationNTFY) SendManualNeeded(results []model.ActionResult) error {
|
||||
body, ok := manualNeededText(results)
|
||||
if strings.TrimSpace(n.URL) == "" || !ok {
|
||||
return nil
|
||||
}
|
||||
client := n.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, n.URL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.Header.Set("Title", manualNeededSubject)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("notify returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NotificationSMTP struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func (s NotificationSMTP) SendManualNeeded(results []model.ActionResult) error {
|
||||
body, ok := manualNeededText(results)
|
||||
if strings.TrimSpace(s.Host) == "" || !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(s.Host)
|
||||
addr := net.JoinHostPort(host, strings.TrimSpace(s.Port))
|
||||
|
||||
from, err := mail.ParseAddress(strings.TrimSpace(s.From))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse smtp from: %w", err)
|
||||
}
|
||||
recipients, err := mail.ParseAddressList(strings.TrimSpace(s.To))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse smtp to: %w", err)
|
||||
}
|
||||
|
||||
to := make([]string, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
to = append(to, recipient.Address)
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
if strings.TrimSpace(s.Username) != "" || strings.TrimSpace(s.Password) != "" {
|
||||
auth = smtp.PlainAuth("", strings.TrimSpace(s.Username), strings.TrimSpace(s.Password), host)
|
||||
}
|
||||
|
||||
message := strings.Builder{}
|
||||
message.WriteString(fmt.Sprintf("From: %s\r\n", from.String()))
|
||||
message.WriteString(fmt.Sprintf("To: %s\r\n", formatAddressList(recipients)))
|
||||
message.WriteString(fmt.Sprintf("Subject: %s\r\n", manualNeededSubject))
|
||||
message.WriteString("MIME-Version: 1.0\r\n")
|
||||
message.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
message.WriteString("\r\n")
|
||||
message.WriteString(body)
|
||||
|
||||
if err := smtp.SendMail(addr, auth, from.Address, to, []byte(message.String())); err != nil {
|
||||
return fmt.Errorf("send smtp notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualNeededText(results []model.ActionResult) (string, bool) {
|
||||
var body strings.Builder
|
||||
manualCount := 0
|
||||
body.WriteString("nCore HnR torrents need manual work:\n")
|
||||
for _, result := range results {
|
||||
if !result.ManualNeeded {
|
||||
continue
|
||||
}
|
||||
manualCount++
|
||||
body.WriteString(fmt.Sprintf("- %s", result.Torrent.Name))
|
||||
if result.QBit != nil {
|
||||
body.WriteString(fmt.Sprintf(" (qBit: %s, %.1f%%)", result.QBit.State, result.QBit.Progress*100))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
}
|
||||
return body.String(), manualCount > 0
|
||||
}
|
||||
|
||||
func formatAddressList(addresses []*mail.Address) string {
|
||||
formatted := make([]string, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
formatted = append(formatted, address.String())
|
||||
}
|
||||
return strings.Join(formatted, ", ")
|
||||
}
|
||||
116
internal/qbit/client.go
Normal file
116
internal/qbit/client.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"ncore-hnr/internal/model"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
httpClient = &http.Client{Jar: jar}
|
||||
}
|
||||
return &Client{baseURL: strings.TrimRight(baseURL, "/"), httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (c *Client) Login(username string, password string) error {
|
||||
values := url.Values{}
|
||||
values.Set("username", username)
|
||||
values.Set("password", password)
|
||||
|
||||
resp, err := c.httpClient.PostForm(c.endpoint("/api/v2/auth/login"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("qBittorrent login returned %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(body)) != "Ok." {
|
||||
return fmt.Errorf("qBittorrent login failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Torrents() ([]model.QBitTorrent, error) {
|
||||
resp, err := c.httpClient.Get(c.endpoint("/api/v2/torrents/info"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("qBittorrent torrent list returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var torrents []model.QBitTorrent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (c *Client) ForceStart(hash string) error {
|
||||
values := url.Values{}
|
||||
values.Set("hashes", hash)
|
||||
values.Set("value", "true")
|
||||
return c.postOK("/api/v2/torrents/setForceStart", values)
|
||||
}
|
||||
|
||||
func (c *Client) Reannounce(hash string) error {
|
||||
values := url.Values{}
|
||||
values.Set("hashes", hash)
|
||||
return c.postOK("/api/v2/torrents/reannounce", values)
|
||||
}
|
||||
|
||||
func MatchByName(ncoreName string, torrents []model.QBitTorrent) (model.QBitTorrent, bool) {
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Name == ncoreName {
|
||||
return torrent, true
|
||||
}
|
||||
}
|
||||
|
||||
normalized := normalizeName(ncoreName)
|
||||
for _, torrent := range torrents {
|
||||
if normalizeName(torrent.Name) == normalized {
|
||||
return torrent, true
|
||||
}
|
||||
}
|
||||
|
||||
return model.QBitTorrent{}, false
|
||||
}
|
||||
|
||||
func (c *Client) postOK(path string, values url.Values) error {
|
||||
resp, err := c.httpClient.PostForm(c.endpoint(path), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("qBittorrent %s returned %s", path, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) endpoint(path string) string {
|
||||
return c.baseURL + path
|
||||
}
|
||||
|
||||
func normalizeName(value string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(value), " "))
|
||||
}
|
||||
347
internal/store/store.go
Normal file
347
internal/store/store.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"ncore-hnr/internal/model"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type State struct {
|
||||
FirstSeenAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if dir := filepath.Dir(path); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{db: db}
|
||||
if err := store.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertSeen(torrent model.Torrent, now time.Time) (State, error) {
|
||||
existing, err := s.state(torrent.ID)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
firstSeen := now
|
||||
if !existing.FirstSeenAt.IsZero() {
|
||||
firstSeen = existing.FirstSeenAt
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO torrents (
|
||||
ncore_id, name, first_seen_at, last_seen_at, status, hnr_marked,
|
||||
uploaded_text, downloaded_text, remaining_text, ratio_text
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ncore_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
status = 'active',
|
||||
hnr_marked = excluded.hnr_marked,
|
||||
uploaded_text = excluded.uploaded_text,
|
||||
downloaded_text = excluded.downloaded_text,
|
||||
remaining_text = excluded.remaining_text,
|
||||
ratio_text = excluded.ratio_text
|
||||
`, torrent.ID, torrent.Name, firstSeen.Format(time.RFC3339), now.Format(time.RFC3339), boolInt(torrent.HnRMarked), torrent.Uploaded, torrent.Downloaded, torrent.Remaining, torrent.Ratio)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
return State{FirstSeenAt: firstSeen, LastSeenAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkResolved(activeIDs map[int64]bool, now time.Time) error {
|
||||
rows, err := s.db.Query(`SELECT ncore_id FROM torrents WHERE status = 'active'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var resolved []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if !activeIDs[id] {
|
||||
resolved = append(resolved, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range resolved {
|
||||
if _, err := s.db.Exec(`UPDATE torrents SET status = 'resolved', last_resolved_at = ? WHERE ncore_id = ?`, now.Format(time.RFC3339), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordQBit(torrent model.Torrent, qbit model.QBitTorrent, now time.Time, forceStarted bool, reannounced bool) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE torrents SET
|
||||
qbit_hash = ?,
|
||||
qbit_name = ?,
|
||||
qbit_state = ?,
|
||||
qbit_progress = ?,
|
||||
qbit_ratio = ?,
|
||||
qbit_uploaded = ?,
|
||||
qbit_downloaded = ?,
|
||||
qbit_last_activity = ?,
|
||||
last_action_at = CASE WHEN ? OR ? THEN ? ELSE last_action_at END
|
||||
WHERE ncore_id = ?
|
||||
`, qbit.Hash, qbit.Name, qbit.State, qbit.Progress, qbit.Ratio, qbit.Uploaded, qbit.Downloaded, qbit.LastActivity, forceStarted, reannounced, now.Format(time.RFC3339), torrent.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkManualNeeded(id int64, now time.Time) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE torrents
|
||||
SET status = 'manual_needed',
|
||||
manual_needed_at = COALESCE(manual_needed_at, ?)
|
||||
WHERE ncore_id = ?
|
||||
`, now.Format(time.RFC3339), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) InsertRun(summary model.RunSummary) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO runs (started_at, dry_run, total_risk, matched, unmatched, force_started, reannounced, manual_needed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, summary.StartedAt.Format(time.RFC3339), boolInt(summary.DryRun), summary.TotalRisk, summary.Matched, summary.Unmatched, summary.ForceStarted, summary.Reannounced, summary.ManualNeeded)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Status() (model.StatusSnapshot, error) {
|
||||
torrents, err := s.torrentStatuses("")
|
||||
if err != nil {
|
||||
return model.StatusSnapshot{}, err
|
||||
}
|
||||
return model.StatusSnapshot{Torrents: torrents}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Stats() (model.StatsSnapshot, error) {
|
||||
counts, err := s.statusCounts()
|
||||
if err != nil {
|
||||
return model.StatsSnapshot{}, err
|
||||
}
|
||||
lastRun, err := s.lastRun()
|
||||
if err != nil {
|
||||
return model.StatsSnapshot{}, err
|
||||
}
|
||||
manualNeeded, err := s.torrentStatuses("manual_needed")
|
||||
if err != nil {
|
||||
return model.StatsSnapshot{}, err
|
||||
}
|
||||
|
||||
return model.StatsSnapshot{
|
||||
Counts: counts,
|
||||
LastRun: lastRun,
|
||||
ManualNeeded: manualNeeded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) state(id int64) (State, error) {
|
||||
var firstText string
|
||||
var lastText string
|
||||
err := s.db.QueryRow(`SELECT first_seen_at, last_seen_at FROM torrents WHERE ncore_id = ?`, id).Scan(&firstText, &lastText)
|
||||
if err == sql.ErrNoRows {
|
||||
return State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
firstSeen, err := time.Parse(time.RFC3339, firstText)
|
||||
if err != nil {
|
||||
return State{}, fmt.Errorf("parse first_seen_at for %d: %w", id, err)
|
||||
}
|
||||
lastSeen, err := time.Parse(time.RFC3339, lastText)
|
||||
if err != nil {
|
||||
return State{}, fmt.Errorf("parse last_seen_at for %d: %w", id, err)
|
||||
}
|
||||
return State{FirstSeenAt: firstSeen, LastSeenAt: lastSeen}, nil
|
||||
}
|
||||
|
||||
func (s *Store) statusCounts() ([]model.StatusCount, error) {
|
||||
rows, err := s.db.Query(`SELECT status, COUNT(*) FROM torrents GROUP BY status ORDER BY status`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var counts []model.StatusCount
|
||||
for rows.Next() {
|
||||
var count model.StatusCount
|
||||
if err := rows.Scan(&count.Status, &count.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts = append(counts, count)
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) lastRun() (*model.RunRecord, error) {
|
||||
var run model.RunRecord
|
||||
var dryRun int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT started_at, dry_run, total_risk, matched, unmatched, force_started, reannounced, manual_needed
|
||||
FROM runs
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).Scan(&run.StartedAt, &dryRun, &run.TotalRisk, &run.Matched, &run.Unmatched, &run.ForceStarted, &run.Reannounced, &run.ManualNeeded)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.DryRun = dryRun != 0
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
func (s *Store) torrentStatuses(status string) ([]model.TorrentStatus, error) {
|
||||
query := `
|
||||
SELECT
|
||||
ncore_id,
|
||||
name,
|
||||
status,
|
||||
first_seen_at,
|
||||
last_seen_at,
|
||||
COALESCE(last_resolved_at, ''),
|
||||
hnr_marked,
|
||||
COALESCE(qbit_name, ''),
|
||||
COALESCE(qbit_state, ''),
|
||||
COALESCE(qbit_progress, 0),
|
||||
COALESCE(qbit_ratio, 0),
|
||||
COALESCE(last_action_at, ''),
|
||||
COALESCE(manual_needed_at, '')
|
||||
FROM torrents
|
||||
`
|
||||
var args []any
|
||||
if status != "" {
|
||||
query += ` WHERE status = ?`
|
||||
args = append(args, status)
|
||||
}
|
||||
query += `
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'manual_needed' THEN 0
|
||||
WHEN 'active' THEN 1
|
||||
WHEN 'resolved' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
last_seen_at DESC,
|
||||
name ASC
|
||||
`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var torrents []model.TorrentStatus
|
||||
for rows.Next() {
|
||||
var torrent model.TorrentStatus
|
||||
var hnrMarked int
|
||||
if err := rows.Scan(
|
||||
&torrent.ID,
|
||||
&torrent.Name,
|
||||
&torrent.Status,
|
||||
&torrent.FirstSeenAt,
|
||||
&torrent.LastSeenAt,
|
||||
&torrent.LastResolvedAt,
|
||||
&hnrMarked,
|
||||
&torrent.QBitName,
|
||||
&torrent.QBitState,
|
||||
&torrent.QBitProgress,
|
||||
&torrent.QBitRatio,
|
||||
&torrent.LastActionAt,
|
||||
&torrent.ManualNeededAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrent.HnRMarked = hnrMarked != 0
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
return torrents, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS torrents (
|
||||
ncore_id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
last_resolved_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
hnr_marked INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_text TEXT,
|
||||
downloaded_text TEXT,
|
||||
remaining_text TEXT,
|
||||
ratio_text TEXT,
|
||||
qbit_hash TEXT,
|
||||
qbit_name TEXT,
|
||||
qbit_state TEXT,
|
||||
qbit_progress REAL,
|
||||
qbit_ratio REAL,
|
||||
qbit_uploaded INTEGER,
|
||||
qbit_downloaded INTEGER,
|
||||
qbit_last_activity INTEGER,
|
||||
last_action_at TEXT,
|
||||
manual_needed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
dry_run INTEGER NOT NULL,
|
||||
total_risk INTEGER NOT NULL,
|
||||
matched INTEGER NOT NULL,
|
||||
unmatched INTEGER NOT NULL,
|
||||
force_started INTEGER NOT NULL,
|
||||
reannounced INTEGER NOT NULL,
|
||||
manual_needed INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user