This commit is contained in:
Zsolt Alföldi
2026-05-07 00:14:02 +02:00
commit 469e5b0678
18 changed files with 1898 additions and 0 deletions

302
internal/app/app.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}