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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
*
!go.mod
!go.sum
!Dockerfile
!cmd/
!cmd/**
!internal/
!internal/**

27
.env.example Normal file
View 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
View File

@@ -0,0 +1,4 @@
.env
data/
*.sqlite
*.sqlite-*

17
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
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
}

36
k8s/cronjob.yaml Normal file
View 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
View 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
View 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: ""