From db7ad3eec28b8c777a9014efe134a95009a51de2 Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 11 Jul 2026 23:10:14 -0400 Subject: [PATCH] initial commit --- .gitignore | 1 + api/client.go | 61 ++++++++++++++++++++++++++++++++++ api/torrent.go | 57 ++++++++++++++++++++++++++++++++ api/util_time.go | 21 ++++++++++++ build.sh | 2 ++ go.mod | 11 +++++++ go.sum | 8 +++++ main.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 247 insertions(+) create mode 100644 .gitignore create mode 100644 api/client.go create mode 100644 api/torrent.go create mode 100644 api/util_time.go create mode 100755 build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e03e15 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.build/ diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..bc2b8ae --- /dev/null +++ b/api/client.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" + + "github.com/carlmjohnson/requests" + "golang.org/x/net/publicsuffix" +) + +type Client struct { + baseURL string + inner http.Client +} + +func NewClient(baseURL, username, password string) (*Client, error) { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return nil, err + } + c := Client{ + baseURL: baseURL, + inner: http.Client{ + Timeout: time.Second * 10, + Jar: jar, + }, + } + if err := c.login(username, password); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Client) reqBuilder() *requests.Builder { + return requests. + URL(c.baseURL). + Client(&c.inner). + AddValidator(requests.ValidatorHandler(requests.DefaultValidator, requests.ResponseHandler(func(r *http.Response) error { + var buf strings.Builder + _, _ = io.Copy(&buf, r.Body) + return fmt.Errorf("response body: %v", buf.String()) + }))) +} + +func (c *Client) login(username, password string) error { + err := c.reqBuilder(). + Path("/api/v2/auth/login"). + Header("Referer", c.baseURL). + BodyForm(url.Values{"username": []string{username}, "password": []string{password}}). + Fetch(context.TODO()) + if err != nil { + return fmt.Errorf("login: %w", err) + } + return nil +} diff --git a/api/torrent.go b/api/torrent.go new file mode 100644 index 0000000..91c13b5 --- /dev/null +++ b/api/torrent.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "net/url" + "strconv" +) + +type TorrentInfo struct { + Name string `json:"name"` + Hash string `json:"hash"` + State string `json:"state"` + Category string `json:"category"` + AddedOn UnixTimestamp `json:"added_on"` + AmountLeft int64 `json:"amount_left"` + Downloaded int64 `json:"downloaded"` + Size int64 `json:"size"` + TotalSize int64 `json:"total_size"` +} + +func (c *Client) LisTorrents() ([]TorrentInfo, error) { + var torrents []TorrentInfo + if err := c.reqBuilder().Path("/api/v2/torrents/info").ToJSON(&torrents).Fetch(context.TODO()); err != nil { + return nil, err + } + return torrents, nil +} + +type TorrentFile struct { + Index int32 `json:"index"` + Name string `json:"name"` + Size int64 `json:"size"` + Progress float64 `json:"progress"` + Priority uint8 `json:"priority"` + IsSeed bool `json:"is_seed"` + PieceRange []int64 `json:"piece_range"` + Availability float64 `json:"availability"` +} + +func (c *Client) TorrentFiles(hash string) ([]TorrentFile, error) { + var files []TorrentFile + if err := c.reqBuilder().Path("/api/v2/torrents/files").Param("hash", hash).ToJSON(&files).Fetch(context.TODO()); err != nil { + return nil, err + } + return files, nil +} + +func (c *Client) DeleteTorrent(hash string, deleteFiles bool) error { + err := c.reqBuilder(). + Path("/api/v2/torrents/delete"). + BodyForm(url.Values{ + "hashes": []string{hash}, + "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, + }). + Fetch(context.TODO()) + return err +} diff --git a/api/util_time.go b/api/util_time.go new file mode 100644 index 0000000..737f911 --- /dev/null +++ b/api/util_time.go @@ -0,0 +1,21 @@ +package api + +import ( + "encoding/json" + "time" +) + +type UnixTimestamp struct { + time.Time +} + +func (ut *UnixTimestamp) UnmarshalJSON(b []byte) error { + var timestamp int64 + // Unmarshal the raw JSON bytes into an int64 + if err := json.Unmarshal(b, ×tamp); err != nil { + return err + } + // Convert the epoch seconds to a time.Time object + ut.Time = time.Unix(timestamp, 0) + return nil +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..fb6fb87 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +#!/bin/sh +CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o .build/qbittorrent-sentinel diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..473640c --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.sbstp.ca/sbstp/qbittorrent-sentinel + +go 1.26.2 + +require ( + github.com/carlmjohnson/requests v0.25.1 + github.com/samber/lo v1.53.0 + golang.org/x/net v0.57.0 +) + +require golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae7f149 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/carlmjohnson/requests v0.25.1 h1:17zNRLecxtAjhtdEIV+F+wrYfe+AGZUjWJtpndcOUYA= +github.com/carlmjohnson/requests v0.25.1/go.mod h1:z3UEf8IE4sZxZ78spW6/tLdqBkfCu1Fn4RaYMnZ8SRM= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f7ea021 --- /dev/null +++ b/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "log/slog" + "os" + "regexp" + "time" + + "git.sbstp.ca/sbstp/qbittorrent-sentinel/api" + "github.com/samber/lo" +) + +var rxExecutable = regexp.MustCompile(`\.(exe|scr|lnk|bat|vbs|js|cmd)$`) + +func isExecutable(t *api.TorrentFile) bool { + // - File has extension commonly used for viruses/executables + return rxExecutable.MatchString(t.Name) +} + +func isCandidate(t *api.TorrentInfo) bool { + // - Less downloaded than total size, indicates it's stalled + // - Size is less than total size, indicates some files have been filtered out + return t.Downloaded < t.TotalSize && t.Size < t.TotalSize && time.Since(t.AddedOn.Time) > time.Minute*15 +} + +func checkTorrents(baseURL, username, password string) error { + client, err := api.NewClient(baseURL, username, password) + if err != nil { + slog.Error("Could not connect to qBittorrent", slog.String("error", err.Error())) + return err + } + + list, err := client.LisTorrents() + if err != nil { + slog.Error("Could not list torrents", slog.String("error", err.Error())) + return err + } + slog.Info("Successfully listed torrents", slog.Int("num", len(list))) + + for _, t := range list { + if isCandidate(&t) { + slog.Info("Torrent is a candidate", slog.String("hash", t.Hash), slog.String("name", t.Name)) + files, err := client.TorrentFiles(t.Hash) + if err != nil { + slog.Error("Could not get torrent files", slog.String("error", err.Error())) + continue + } + + executables := lo.Filter(files, func(f api.TorrentFile, _ int) bool { + return isExecutable(&f) + }) + if len(executables) > 0 { + slog.Info("Torrent contains executables") + for _, exe := range executables { + slog.Info("Torrent file is executable", slog.String("name", exe.Name)) + } + slog.Info("Deleting torrent...") + if err := client.DeleteTorrent(t.Hash, true); err != nil { + slog.Error("Failed to delete torrent", slog.String("error", err.Error())) + } else { + slog.Info("Torrent deleted successfully") + } + } + + } + + } + + slog.Info("Done") + return nil +} + +func main() { + baseURL := os.Getenv("QBITTORRENT_BASE_URL") + username := os.Getenv("QBITTORRENT_USERNAME") + password := os.Getenv("QBITTORRENT_PASSWORD") + + if len(baseURL) == 0 { + slog.Error("QBITTORRENT_BASE_URL environment variable is required") + os.Exit(2) + } + + if err := checkTorrents(baseURL, username, password); err != nil { + os.Exit(1) + } +}