commit 469e5b0678818a840010dfeab5b018599a06b2c1 Author: Zsolt Alföldi Date: Thu May 7 00:14:02 2026 +0200 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..295b8c5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* + +!go.mod +!go.sum +!Dockerfile +!cmd/ +!cmd/** +!internal/ +!internal/** diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4964d0a --- /dev/null +++ b/.env.example @@ -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' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c29c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/ +*.sqlite +*.sqlite-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2bdf6fb --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c4ffe8 --- /dev/null +++ b/README.md @@ -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 ' +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. diff --git a/cmd/ncore-hnr/main.go b/cmd/ncore-hnr/main.go new file mode 100644 index 0000000..b5e302f --- /dev/null +++ b/cmd/ncore-hnr/main.go @@ -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 + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9eccea1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..81143e6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..a2b3473 --- /dev/null +++ b/internal/app/app.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7609ae3 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..6fd7e84 --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/ncore/client.go b/internal/ncore/client.go new file mode 100644 index 0000000..8f7e10a --- /dev/null +++ b/internal/ncore/client.go @@ -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 +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..99c416e --- /dev/null +++ b/internal/notify/notify.go @@ -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, ", ") +} diff --git a/internal/qbit/client.go b/internal/qbit/client.go new file mode 100644 index 0000000..d58f577 --- /dev/null +++ b/internal/qbit/client.go @@ -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), " ")) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..d94a513 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/k8s/cronjob.yaml b/k8s/cronjob.yaml new file mode 100644 index 0000000..dc5a9dd --- /dev/null +++ b/k8s/cronjob.yaml @@ -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 diff --git a/k8s/pvc.yaml b/k8s/pvc.yaml new file mode 100644 index 0000000..48e30ef --- /dev/null +++ b/k8s/pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ncore-hnr-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8s/secret.example.yaml b/k8s/secret.example.yaml new file mode 100644 index 0000000..69c7fd2 --- /dev/null +++ b/k8s/secret.example.yaml @@ -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: ""