init
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
*
|
||||
|
||||
!go.mod
|
||||
!go.sum
|
||||
!Dockerfile
|
||||
!cmd/
|
||||
!cmd/**
|
||||
!internal/
|
||||
!internal/**
|
||||
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
NCORE_USERNAME='your-ncore-username'
|
||||
NCORE_PASSWORD='your-ncore-password'
|
||||
|
||||
QBITTORRENT_URL='http://localhost:8080'
|
||||
QBITTORRENT_USERNAME='admin'
|
||||
QBITTORRENT_PASSWORD='your-qbit-password'
|
||||
|
||||
APP_DB_PATH='data/ncore-hnr.sqlite'
|
||||
ALERT_AFTER='48h'
|
||||
|
||||
# Optional: leave NOTIFICATION_TYPE empty to disable alerts.
|
||||
# Use 'ntfy' or 'smtp'. If NOTIFICATION_NTFY_URL is set, ntfy is selected automatically.
|
||||
NOTIFICATION_TYPE=''
|
||||
|
||||
# ntfy.sh example: https://ntfy.sh/your-secret-topic
|
||||
NOTIFICATION_NTFY_URL=''
|
||||
|
||||
# SMTP example for Gmail: host smtp.gmail.com, port 587, username your Gmail address,
|
||||
# password a Gmail app password, from your Gmail address, to your target email.
|
||||
NOTIFICATION_SMTP_HOST=''
|
||||
NOTIFICATION_SMTP_PORT='587'
|
||||
NOTIFICATION_SMTP_USERNAME=''
|
||||
NOTIFICATION_SMTP_PASSWORD=''
|
||||
NOTIFICATION_SMTP_FROM=''
|
||||
NOTIFICATION_SMTP_TO=''
|
||||
|
||||
DRY_RUN='false'
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.25-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ncore-hnr ./cmd/ncore-hnr
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/ncore-hnr /usr/local/bin/ncore-hnr
|
||||
|
||||
VOLUME ["/data"]
|
||||
ENTRYPOINT ["/usr/local/bin/ncore-hnr"]
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# ncore-hnr
|
||||
|
||||
Go app for checking nCore hit'n'run-risk torrents, force-starting matching qBittorrent torrents, and tracking state in SQLite.
|
||||
|
||||
## Configuration
|
||||
|
||||
Put local config in `.env`:
|
||||
|
||||
```bash
|
||||
NCORE_USERNAME='your-ncore-username'
|
||||
NCORE_PASSWORD='your-ncore-password'
|
||||
|
||||
QBITTORRENT_URL='http://localhost:8080'
|
||||
QBITTORRENT_USERNAME='admin'
|
||||
QBITTORRENT_PASSWORD='your-qbit-password'
|
||||
|
||||
APP_DB_PATH='data/ncore-hnr.sqlite'
|
||||
DRY_RUN='false'
|
||||
ALERT_AFTER='48h'
|
||||
|
||||
# Optional notifications: set NOTIFICATION_TYPE to 'ntfy' or 'smtp'.
|
||||
NOTIFICATION_TYPE=''
|
||||
|
||||
NOTIFICATION_NTFY_URL=''
|
||||
|
||||
NOTIFICATION_SMTP_HOST=''
|
||||
NOTIFICATION_SMTP_PORT='587'
|
||||
NOTIFICATION_SMTP_USERNAME=''
|
||||
NOTIFICATION_SMTP_PASSWORD=''
|
||||
NOTIFICATION_SMTP_FROM=''
|
||||
NOTIFICATION_SMTP_TO=''
|
||||
```
|
||||
|
||||
`DRY_RUN=false` is the default and will call qBittorrent `setForceStart` and `reannounce`. Use `--dry-run=true` when you only want to preview matches.
|
||||
|
||||
Set `NOTIFICATION_TYPE=ntfy` and `NOTIFICATION_NTFY_URL='https://ntfy.sh/your-secret-topic'` to send manual-needed alerts through ntfy. For compatibility, old `NOTIFY_URL` values still work as the ntfy URL.
|
||||
|
||||
Set `NOTIFICATION_TYPE=smtp` to send manual-needed alerts by email. For Gmail, use:
|
||||
|
||||
```bash
|
||||
NOTIFICATION_SMTP_HOST='smtp.gmail.com'
|
||||
NOTIFICATION_SMTP_PORT='587'
|
||||
NOTIFICATION_SMTP_USERNAME='youraddress@gmail.com'
|
||||
NOTIFICATION_SMTP_PASSWORD='your-gmail-app-password'
|
||||
NOTIFICATION_SMTP_FROM='ncore <youraddress@gmail.com>'
|
||||
NOTIFICATION_SMTP_TO='target@example.com'
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr
|
||||
```
|
||||
|
||||
Preview without qBittorrent actions:
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr --dry-run=true
|
||||
```
|
||||
|
||||
JSON output:
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr --json
|
||||
```
|
||||
|
||||
## SQLite Views
|
||||
|
||||
Show aggregate statistics and the latest run:
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr stats
|
||||
```
|
||||
|
||||
Show tracked torrent status rows:
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr status
|
||||
```
|
||||
|
||||
Both read-only commands support JSON and a custom DB path:
|
||||
|
||||
```bash
|
||||
go run ./cmd/ncore-hnr status --json
|
||||
go run ./cmd/ncore-hnr stats --db data/ncore-hnr.sqlite
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t ncore-hnr:local .
|
||||
docker run --rm --env-file .env -v "$PWD/data:/data" ncore-hnr:local
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
|
||||
The `k8s/` folder contains a CronJob, PVC, and example Secret. Store real secrets out of git.
|
||||
96
cmd/ncore-hnr/main.go
Normal file
96
cmd/ncore-hnr/main.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"ncore-hnr/internal/app"
|
||||
"ncore-hnr/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
command, args := splitCommand(os.Args[1:])
|
||||
|
||||
switch command {
|
||||
case "stats":
|
||||
runStats(args)
|
||||
case "status":
|
||||
runStatus(args)
|
||||
case "run":
|
||||
runCheck(args)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "unknown command:", command)
|
||||
fmt.Fprintln(os.Stderr, "usage: ncore-hnr [run|stats|status] [flags]")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runCheck(args []string) {
|
||||
cfg, err := config.Load(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
summary, err := app.Run(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "run error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := app.PrintSummary(summary, cfg.JSONOutput); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "output error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runStats(args []string) {
|
||||
cfg, err := config.LoadReadOnly(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
snapshot, err := app.LoadStats(cfg.DBPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "stats error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := app.PrintStats(snapshot, cfg.JSONOutput); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "output error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runStatus(args []string) {
|
||||
cfg, err := config.LoadReadOnly(args)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
snapshot, err := app.LoadStatus(cfg.DBPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "status error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := app.PrintStatus(snapshot, cfg.JSONOutput); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "output error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCommand(args []string) (string, []string) {
|
||||
if len(args) == 0 {
|
||||
return "run", args
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "run", "stats", "status":
|
||||
return args[0], args[1:]
|
||||
default:
|
||||
return "run", args
|
||||
}
|
||||
}
|
||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
||||
module ncore-hnr
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
122
go.sum
Normal file
122
go.sum
Normal file
@@ -0,0 +1,122 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
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
|
||||
}
|
||||
36
k8s/cronjob.yaml
Normal file
36
k8s/cronjob.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: ncore-hnr
|
||||
spec:
|
||||
schedule: "0 6 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: ncore-hnr
|
||||
image: ncore-hnr:local
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: ncore-hnr-secrets
|
||||
env:
|
||||
- name: APP_DB_PATH
|
||||
value: /data/ncore-hnr.sqlite
|
||||
- name: DRY_RUN
|
||||
value: "false"
|
||||
- name: ALERT_AFTER
|
||||
value: 48h
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ncore-hnr-data
|
||||
10
k8s/pvc.yaml
Normal file
10
k8s/pvc.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ncore-hnr-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
19
k8s/secret.example.yaml
Normal file
19
k8s/secret.example.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ncore-hnr-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
NCORE_USERNAME: "your-ncore-username"
|
||||
NCORE_PASSWORD: "your-ncore-password"
|
||||
QBITTORRENT_URL: "http://qbittorrent.default.svc.cluster.local:8080"
|
||||
QBITTORRENT_USERNAME: "admin"
|
||||
QBITTORRENT_PASSWORD: "your-qbit-password"
|
||||
NOTIFICATION_TYPE: ""
|
||||
NOTIFICATION_NTFY_URL: ""
|
||||
NOTIFICATION_SMTP_HOST: ""
|
||||
NOTIFICATION_SMTP_PORT: "587"
|
||||
NOTIFICATION_SMTP_USERNAME: ""
|
||||
NOTIFICATION_SMTP_PASSWORD: ""
|
||||
NOTIFICATION_SMTP_FROM: ""
|
||||
NOTIFICATION_SMTP_TO: ""
|
||||
Reference in New Issue
Block a user