// SPDX-FileCopyrightText: 2022-2024 Simon Bruder // // SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "context" "encoding/json" "io" "log" "net" "net/http" "net/url" "os" "strconv" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/exporter-toolkit/web" ) type ApiClient struct { apiSocket string } type ApiError struct { ReturnCode int } type TorrentInfo struct { Auto bool `json:"auto_tmm"` DownloadLimit int `json:"dl_limit"` Downloaded int `json:"completed"` Hash string `json:"hash"` LeechsConnected int `json:"num_leechs"` LeechsSwarm int `json:"num_incomplete"` Name string `json:"name"` SeedsConnected int `json:"num_seeds"` SeedsSwarm int `json:"num_complete"` Size int `json:"size"` TotalSize int `json:"total_size"` UploadLimit int `json:"ul_limit"` Uploaded int `json:"uploaded"` } type Exporter struct { Api ApiClient } var ( qbittorrentTorrentDownloaded = prometheus.NewDesc( "qbittorrent_torrent_downloaded_bytes_total", "Amount of data downloaded", []string{"hash", "name"}, nil, ) qbittorrentTorrentLeechsConnected = prometheus.NewDesc( "qbittorrent_torrent_leechs_connected", "Number of leechs connected to", []string{"hash", "name"}, nil, ) qbittorrentTorrentLeechsSwarm = prometheus.NewDesc( "qbittorrent_torrent_leechs_swarm", "Number of leechs in the swarm", []string{"hash", "name"}, nil, ) qbittorrentTorrentSeedsConnected = prometheus.NewDesc( "qbittorrent_torrent_seeds_connected", "Number of seeds connected to", []string{"hash", "name"}, nil, ) qbittorrentTorrentSeedsSwarm = prometheus.NewDesc( "qbittorrent_torrent_seeds_swarm", "Number of seeds in the swarm", []string{"hash", "name"}, nil, ) qbittorrentTorrentSize = prometheus.NewDesc( "qbittorrent_torrent_size_bytes_total", "Size of selected torrent data", []string{"hash", "name"}, nil, ) qbittorrentTorrentUploaded = prometheus.NewDesc( "qbittorrent_torrent_uploaded_bytes_total", "Amount of data uploaded", []string{"hash", "name"}, nil, ) ) func (e ApiError) Error() string { return strconv.Itoa(e.ReturnCode) } func CreateApiClient(apiSocket string) (c ApiClient) { return ApiClient{ apiSocket: apiSocket, } } func (c ApiClient) doRequest(group string, method string, parameters url.Values) (body []byte, err error) { destinationUrl, err := url.Parse("http://unix/api/v2/" + group + "/" + method) if err != nil { log.Println(err) return []byte{}, err } req := http.Request{ Method: "GET", URL: destinationUrl, Header: http.Header{}, Form: parameters, } httpClient := http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", c.apiSocket) }, }, } res, err := httpClient.Do(&req) if err != nil { log.Println(err) return []byte{}, err } bodyBytes, err := io.ReadAll(res.Body) if err != nil { return []byte{}, err } body = bodyBytes if res.StatusCode != 200 { err = ApiError{ReturnCode: res.StatusCode} } return } func (c ApiClient) TorrentsInfo() (torrentsInfo []TorrentInfo, err error) { torrentsInfo = []TorrentInfo{} body, err := c.doRequest("torrents", "info", url.Values{}) if err != nil { return []TorrentInfo{}, err } json.Unmarshal(body, &torrentsInfo) return } func (e Exporter) Describe(ch chan<- *prometheus.Desc) { ch <- qbittorrentTorrentDownloaded ch <- qbittorrentTorrentLeechsConnected ch <- qbittorrentTorrentLeechsSwarm ch <- qbittorrentTorrentSeedsConnected ch <- qbittorrentTorrentSeedsSwarm ch <- qbittorrentTorrentSize ch <- qbittorrentTorrentUploaded } func (e Exporter) Collect(ch chan<- prometheus.Metric) { torrentsInfo, err := e.Api.TorrentsInfo() if err != nil { log.Println(err) return } for _, torrentInfo := range torrentsInfo { ch <- prometheus.MustNewConstMetric(qbittorrentTorrentDownloaded, prometheus.CounterValue, float64(torrentInfo.Downloaded), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentLeechsConnected, prometheus.GaugeValue, float64(torrentInfo.LeechsConnected), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentLeechsSwarm, prometheus.GaugeValue, float64(torrentInfo.LeechsSwarm), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentSeedsConnected, prometheus.GaugeValue, float64(torrentInfo.SeedsConnected), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentSeedsSwarm, prometheus.GaugeValue, float64(torrentInfo.SeedsSwarm), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentSize, prometheus.GaugeValue, float64(torrentInfo.Size), torrentInfo.Hash, torrentInfo.Name) ch <- prometheus.MustNewConstMetric(qbittorrentTorrentUploaded, prometheus.CounterValue, float64(torrentInfo.Uploaded), torrentInfo.Hash, torrentInfo.Name) } } func main() { e := Exporter{ Api: CreateApiClient( os.Getenv("QBITTORRENT_API_SOCKET"), ), } prometheus.MustRegister(e) http.Handle("/metrics", promhttp.Handler()) landingPage, err := web.NewLandingPage(web.LandingConfig{ Name: "qBittorrent Exporter", Links: []web.LandingLinks{ { Address: "/metrics", Text: "Metrics", }, { Address: "https://git.sbruder.de/simon/nixos-config/src/branch/master/modules/qbittorrent/exporter", Text: "Source Code", }, { Address: "https://www.gnu.org/licenses/agpl-3.0.txt", Text: "Released under the AGPLv3 or later", }, }, }) if err != nil { log.Fatalf("Failed to create landing page: %v", err) } http.Handle("/", landingPage) listenAddress := os.Getenv("QBITTORRENT_EXPORTER_LISTEN_ADDRESS") if listenAddress == "" { listenAddress = ":9561" // this reuses the port number of fru1tstand’s exporter } log.Printf("Starting HTTP server at at %s", listenAddress) log.Fatalf("Failed to start http server: %v", http.ListenAndServe(listenAddress, nil)) }