70 Commits
1.1.1 ... 1.4.0

Author SHA1 Message Date
Esteban Sánchez
75db331c55 Improved docker push github action 2023-11-21 10:32:06 +01:00
Esteban Sánchez
d5c7cbc4a9 Improved docker push github action 2023-11-21 10:23:27 +01:00
Esteban Sánchez
234a4cfffa Improved docker push github action 2023-11-21 10:05:25 +01:00
Esteban Sánchez
4ad22a60da Improved docker push github action 2023-11-21 10:02:05 +01:00
Esteban Sánchez
6c87b8eff9 Improved docker push github action 2023-11-21 09:59:47 +01:00
Esteban Sánchez
e2a8508cae Improved docker push github action 2023-11-21 09:44:22 +01:00
Esteban Sánchez
a6a4129daf Improved docker push github action 2023-11-21 09:30:07 +01:00
Esteban Sánchez
723872bc15 Improved docker push github action 2023-11-21 09:24:33 +01:00
Esteban Sánchez
cb78dd1fb7 Bumped action versions 2023-11-21 09:14:27 +01:00
Esteban Sánchez
8e08565d6e License badce 2023-11-20 18:39:44 +01:00
Esteban Sánchez
56a2792c2e Added more unittests (#27) 2023-11-20 16:55:38 +01:00
Esteban Sánchez
1d154dd224 Added more unittests (#26)
* Added more unittests

* Added more unittests
2023-11-20 15:05:58 +01:00
Esteban Sánchez
9ff35e48c3 Added .dockerignore (#25)
* Added .dockerignore

* Fixed duplicated
2023-11-20 14:26:43 +01:00
Esteban Sánchez
a0929f84ba Added docker pulls badges 2023-11-20 13:51:18 +01:00
Esteban Sánchez
06f13a44a2 Update README.md 2023-11-20 13:47:53 +01:00
Esteban Sánchez
cbb2b6b4b1 Added logo and badges 2023-11-20 13:44:33 +01:00
Esteban Sánchez
e924e30cf8 Setup Dependabot (#24)
* Create dependabot.yml

* Update dependabot.yml

* Update dependabot.yml
2023-11-20 13:23:12 +01:00
Esteban Sánchez
1a04a83c4a Run also on master push 2023-11-20 13:14:45 +01:00
Esteban Sánchez
03e4222043 Unit tests (#23)
* Added unit tests and linting tools for development

* Added pre-commit config file

* Added lint github action

* Add tests and coverage comments to PRs

* Set coverage options

* Fixed yaml

* Fixed permissions

* Omit tests in coverage report

* Run linting only on PRs
2023-11-20 13:12:43 +01:00
Esteban Sánchez
03a9ac78c6 Fixed build workflow 2023-11-20 10:42:12 +01:00
Esteban Sánchez
a60f3d23a3 Update pyproject.toml 2023-11-20 10:36:17 +01:00
Esteban Sánchez
dcaf2a6557 Release v1.4.0 2023-11-20 10:35:10 +01:00
Esteban Sánchez
6d2d7e666a Merge pull request #21 from joelheaps/python311-upgrade
Python 3.11 upgrade and code refactor
2023-11-20 10:24:05 +01:00
Joel Heaps
86b5feddf6 Ignore and remove .pdm-python file 2023-09-29 17:28:20 +00:00
Joel Heaps
4385a0a78f Add config.env.example 2023-09-29 11:09:10 -05:00
Joel Heaps
71416a79ea Edit docstring 2023-09-29 10:51:52 -05:00
Joel Heaps
330d8ec86a Add type hints to get_config_value 2023-09-29 10:50:01 -05:00
Joel Heaps
daed737f73 Minor refactor of config functions, signal handler 2023-09-29 10:48:26 -05:00
Joel Heaps
55e37eb814 Use imported TorrenStates, decompose large method 2023-09-29 10:24:49 -05:00
Joel Heaps
ea4bdecdcf Remove unneeded attridict dependency, add comments 2023-09-29 09:46:51 -05:00
Joel Heaps
0ac406c476 Indicate license in pyproject.toml 2023-09-29 08:13:23 -05:00
Joel Heaps
3061eb1638 Match helptext to README text 2023-09-29 08:13:12 -05:00
Joel Heaps
e56a713355 Remove legacy setup files 2023-09-29 08:02:21 -05:00
Joel Heaps
cd3911f53e Bump Python container version 2023-09-29 08:01:45 -05:00
Joel Heaps
50ca1e4162 Refactor - add enums, dataclasses, more type hints 2023-09-28 23:10:21 -05:00
Joel Heaps
45e8381d70 Minor grammar fixup 2023-09-28 22:25:22 -05:00
Joel Heaps
2f07f00592 Use enum for possible torrent statuses 2023-09-28 22:24:49 -05:00
Joel Heaps
e8c0141365 Minor grammar fixes in help notes 2023-09-28 22:14:14 -05:00
Joel Heaps
de1d0fa7d5 Add main block for easier debugging 2023-09-28 22:09:14 -05:00
Joel Heaps
31f309c942 Switch to attridict, format with Black 2023-09-28 22:07:25 -05:00
Joel Heaps
1fc4383a95 Bump dependency versions 2023-09-28 21:57:48 -05:00
Joel Heaps
78cee60afd Ignore config.env temporarily 2023-09-28 21:57:22 -05:00
Joel Heaps
f4a5266125 Bump dependency versions 2023-09-28 21:56:45 -05:00
Joel Heaps
40f721c447 Switch to pyproject.toml 2023-09-28 21:40:32 -05:00
Esteban Sánchez
a25ce3f7a2 Upgraded libs and set python version limitation 2023-05-09 16:48:15 +02:00
Esteban Sánchez
2f32c69f50 Style correction 2023-05-09 16:48:15 +02:00
Esteban Sánchez
5773684369 Added an option to verify web UI SSL certificate or not 2023-05-09 16:48:15 +02:00
Esteban Sánchez
70ec628045 Use official python image 2023-05-09 16:48:15 +02:00
Esteban Sánchez
af0902b742 Bumped version 2023-05-09 16:48:15 +02:00
Esteban Sánchez
1d0707674a Merge pull request #18 from chrisjohnson00/issue-17
fix: update grafana dashboard for v8 and k8s issue-17
2023-05-09 16:10:34 +02:00
Esteban Sánchez
940b5c6242 Merge pull request #20 from jsawatzky/master
Return metrics on connection error
2023-05-09 15:52:59 +02:00
Jacob Sawatzky
f47ebe94d0 Fix typo 2023-04-25 21:10:56 -04:00
Jacob Sawatzky
e1ed34147c Return matrics on connection error 2023-04-25 21:06:30 -04:00
Chris Johnson
1b2cf9065b fix: data source was hardcoded 2023-03-16 12:35:26 -07:00
Chris Johnson
5b2ed75850 fix: update grafana dashboard for v8 and k8s issue-17
This fixed issues #17
2023-03-16 12:03:39 -07:00
Esteban Sánchez
bc8676ed30 Changed reference to use GHCR 2023-03-16 16:44:08 +01:00
Esteban Sanchez
fb12d50373 Merge pull request #14 from averyanalex/master
Update dependencies
2022-02-28 09:45:10 +01:00
Alex Averyanov
bb3695ece6 Update dependencies 2022-01-11 22:56:54 +03:00
Esteban Sánchez
a9a3e5d1e6 Push docker image to GHCR 2021-08-13 13:02:04 +02:00
Esteban Sánchez
6c885b5fa0 Version 1.2.0 2021-07-17 23:19:38 +02:00
Esteban Sanchez
039f7a7ef4 Merge pull request #9 from jhollowe/logging-and-signals
Fixed logging and exit errors
2021-07-17 23:18:00 +02:00
Esteban Sanchez
8bdaa48c48 Merge pull request #10 from jhollowe/file-configs
Allow files as config sources
2021-07-17 23:16:48 +02:00
Esteban Sanchez
e8dd24f731 Merge pull request #8 from jhollowe/master
Add multi-arch Docker build support
2021-07-17 23:15:00 +02:00
John Hollowell
12dcad10d5 Setup logging before config loading 2021-07-16 23:24:58 +00:00
John Hollowell
597307c230 Allow files as config source
Allow "FILE__"+config_name to point to a file containing the config value.
This format matches linuxserver.io containers and is comonly used in other containers as well.

e.g. FILE__QBITTORRENT_HOST contains "/run/secrets/q_host" and the contents of the "/run/secrets/q_host" is "1.2.3.4"
2021-07-16 23:24:58 +00:00
John Hollowell
a25005b6a0 Fixed logging and exit errors
* Create logger in module so it is always available (still configured in main())
* Allow signals sent multiple times to forcibly kill the exporter
2021-07-16 23:24:26 +00:00
John Hollowell
cfe62f8115 Remove hardcoded username in docker tags 2021-07-16 21:15:47 +00:00
John Hollowell
b5d20e3fe6 Add multiple architechture build support
Add auto builds for x86, x86_64, and arm64
2021-07-16 21:01:54 +00:00
Esteban Sánchez
0ff6a56f18 Version 1.1.2 2021-07-03 23:33:39 +02:00
Esteban Sánchez
2d968bc8e8 Added attrdict as requirement 2021-07-03 20:59:50 +02:00
21 changed files with 1242 additions and 456 deletions

3
.coveragerc Normal file
View File

@@ -0,0 +1,3 @@
[run]
relative_files = True
omit = tests/*

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
.coverage*
.github
.gitignore
.pdm*
.pre-commit-config.yaml
.vscode
build/
logo.png
tests

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -11,30 +11,63 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout GitHub Action'
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
- name: Extract branch name
shell: bash
run: |
tag=${GITHUB_REF#refs/tags/}
tag=${tag#refs/heads/}
if echo $tag | grep -q -E "[0-9]+\.[0-9]+\.[0-9]+"; then
tag="v$tag"
fi
echo "##[set-output name=tag;]$tag"
id: extract_branch
- name: Docker hub meta
id: meta
uses: docker/metadata-action@v5
with:
flavor: |
latest=true
tags: |
type=semver,pattern=v{{version}}
type=ref,event=branch
type=sha
images: ${{ github.actor }}/prometheus-qbittorrent-exporter
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push docker
uses: docker/build-push-action@v2
- name: Build and push docker to DockerHub
uses: docker/build-push-action@v5
with:
push: true
tags: esanchezm/prometheus-qbittorrent-exporter:latest,esanchezm/prometheus-qbittorrent-exporter:${{ steps.extract_branch.outputs.tag }}
platforms: linux/amd64,linux/arm64,linux/386
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: GHCR Docker meta
id: metaghcr
uses: docker/metadata-action@v5
with:
flavor: |
latest=true
tags: |
type=semver,pattern=v{{version}}
type=ref,event=branch
type=sha
images: ghcr.io/${{ github.actor }}/prometheus-qbittorrent-exporter
- name: Login to Github Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker to Github Container Registry
uses: docker/build-push-action@v5
with:
push: true
platforms: linux/amd64,linux/arm64,linux/386
tags: ${{ steps.metaghcr.outputs.tags }}
labels: ${{ steps.metaghcr.outputs.labels }}

59
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Run Unit Test via Pytest
on:
pull_request:
push:
branches:
- "master"
jobs:
lint-and-test:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
checks: write
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Setup PDM
uses: pdm-project/setup-pdm@v3
- name: Install dependencies
run: pdm install
- name: Lint with black
uses: psf/black@stable
with:
options: "--check --verbose"
- name: Test with pytest
run: |
pdm run pytest --junit-xml=test-results.xml
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: |
test-results.xml
- name: Test with coverage
run: |
pdm run coverage run -m pytest -v -s
- name: Generate Coverage Report
run: |
pdm run coverage report -m
- name: Coverage comment
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3
with:
GITHUB_TOKEN: ${{ github.token }}
- name: Store Pull Request comment to be posted
uses: actions/upload-artifact@v3
if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true'
with:
name: python-coverage-comment-action
path: python-coverage-comment-action.txt

View File

@@ -12,7 +12,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: '3.6'
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -22,5 +22,5 @@ jobs:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist
python -m build
twine upload dist/*

9
.gitignore vendored
View File

@@ -127,3 +127,12 @@ dmypy.json
# Pyre type checker
.pyre/
# Ignore config.env
config.env
# Ignore pdm local files
.pdm-python
# Ignore ruff files
.ruff_cache

29
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,29 @@
repos:
- repo: local
hooks:
- id: black
name: black
stages: [commit]
types: [python]
entry: pdm run black .
language: system
pass_filenames: false
always_run: true
- id: ruff
name: ruff
stages: [commit]
types: [python]
entry: pdm run ruff .
language: system
pass_filenames: false
always_run: true
fail_fast: true
- id: pytest
name: pytest
stages: [commit]
types: [python]
entry: pdm run pytest
language: system
pass_filenames: false
always_run: true
fail_fast: true

View File

@@ -1,8 +1,4 @@
FROM alpine:3.11
# Installing required packages
RUN apk add --update --no-cache \
python3
FROM python:3.11-alpine
# Install package
WORKDIR /code

View File

@@ -1,7 +1,12 @@
# Prometheus qBittorrent exporter
<p align="center">
<img src="https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/master/logo.png" height="230">
</p>
A prometheus exporter for qBitorrent. Get metrics from a server and offers them in a prometheus format.
![](https://img.shields.io/github/license/esanchezm/prometheus-qbittorrent-exporter?style=for-the-badge) ![](https://img.shields.io/maintenance/yes/2023?style=for-the-badge) ![](https://img.shields.io/docker/pulls/esanchezm/prometheus-qbittorrent-exporter?style=for-the-badge) ![](https://img.shields.io/github/forks/esanchezm/prometheus-qbittorrent-exporter?style=for-the-badge) ![](https://img.shields.io/github/stars/esanchezm/prometheus-qbittorrent-exporter?style=for-the-badge) ![](https://img.shields.io/python/required-version-toml?tomlFilePath=https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/master/pyproject.toml&style=for-the-badge) [![Coverage badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/python-coverage-comment-action-data/endpoint.json&label=tests%20coverage&style=for-the-badge)](https://htmlpreview.github.io/?https://github.com/esanchezm/prometheus-qbittorrent-exporter/blob/python-coverage-comment-action-data/htmlcov/index.html)
## How to use it
@@ -20,7 +25,11 @@ qbittorrent-exporter
Another option is run it in a docker container.
```
docker run -e QBITTORRENT_PORT=8080 -e QBITTORRENT_HOST=myserver.local -p 8000:8000 esanchezm/prometheus-qbittorrent-exporter
docker run \
-e QBITTORRENT_PORT=8080 \
-e QBITTORRENT_HOST=myserver.local \
-p 8000:8000 \
ghcr.io/esanchezm/prometheus-qbittorrent-exporter
```
Add this to your prometheus.yml
```
@@ -30,15 +39,16 @@ Add this to your prometheus.yml
```
The application reads configuration using environment variables:
| Environment variable | Default | Description |
| -------------------- | ------------- | ----------- |
| `QBITTORRENT_HOST` | | qbittorrent server hostname |
| `QBITTORRENT_PORT` | | qbittorrent server port |
| `QBITTORRENT_USER` | `""` | qbittorrent username |
| `QBITTORRENT_PASS` | `""` | qbittorrent password |
| `EXPORTER_PORT` | `8000` | Exporter listening port |
| `EXPORTER_LOG_LEVEL` | `INFO` | Log level. One of: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `METRICS_PREFIX` | `qbittorrent` | Prefix to add to all the metrics |
| Environment variable | Default | Description |
| -------------------------- | ------------- | ----------- |
| `QBITTORRENT_HOST` | | qbittorrent server hostname |
| `QBITTORRENT_PORT` | | qbittorrent server port |
| `QBITTORRENT_USER` | `""` | qbittorrent username |
| `QBITTORRENT_PASS` | `""` | qbittorrent password |
| `EXPORTER_PORT` | `8000` | Exporter listening port |
| `EXPORTER_LOG_LEVEL` | `INFO` | Log level. One of: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `METRICS_PREFIX` | `qbittorrent` | Prefix to add to all the metrics |
| `VERIFY_WEBUI_CERTIFICATE` | `True` | Whether to verify SSL certificate when connecting to the qbittorrent server. Any other value but `True` will disable the verification |
## Metrics
@@ -48,12 +58,12 @@ These are the metrics this program exports, assuming the `METRICS_PREFIX` is `qb
| Metric name | Type | Description |
| --------------------------------------------------- | -------- | ---------------- |
| `qbittorrent_up` | gauge | Whether if the qBittorrent server is answering requests from this exporter. A `version` label with the server version is added |
| `qbittorrent_connected` | gauge | Whether if the qBittorrent server is connected to the Bittorrent network. |
| `qbittorrent_firewalled` | gauge | Whether if the qBittorrent server is connected to the Bittorrent network but is behind a firewall. |
| `qbittorrent_dht_nodes` | gauge | Number of DHT nodes connected to |
| `qbittorrent_dl_info_data` | counter | Data downloaded since the server started, in bytes |
| `qbittorrent_up_info_data` | counter | Data uploaded since the server started, in bytes |
| `qbittorrent_up` | gauge | Whether the qBittorrent server is answering requests from this exporter. A `version` label with the server version is added. |
| `qbittorrent_connected` | gauge | Whether the qBittorrent server is connected to the Bittorrent network. |
| `qbittorrent_firewalled` | gauge | Whether the qBittorrent server is connected to the Bittorrent network but is behind a firewall. |
| `qbittorrent_dht_nodes` | gauge | Number of DHT nodes connected to. |
| `qbittorrent_dl_info_data` | counter | Data downloaded since the server started, in bytes. |
| `qbittorrent_up_info_data` | counter | Data uploaded since the server started, in bytes. |
| `qbittorrent_torrents_count` | gauge | Number of torrents for each `category` and `status`. Example: `qbittorrent_torrents_count{category="movies",status="downloading"}`|
## Screenshot

6
config.env.example Normal file
View File

@@ -0,0 +1,6 @@
QBITTORRENT_HOST=localhost
QBITTORRENT_PORT=8080
QBITTORRENT_USER=admin
QBITTORRENT_PASS=adminadmin
EXPORTER_PORT=8000
METRICS_PREFIX=qbittorrent

View File

@@ -1,101 +1,60 @@
{
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{
"type": "panel",
"id": "gauge",
"name": "Gauge",
"version": ""
},
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "7.2.2"
},
{
"type": "panel",
"id": "grafana-piechart-panel",
"name": "Pie Chart",
"version": "1.6.1"
},
{
"type": "panel",
"id": "graph",
"name": "Graph",
"version": ""
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "stat",
"name": "Stat",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"iteration": 1603732792335,
"id": 5,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [
{
"from": "",
"id": 0,
"text": "Online",
"to": "",
"type": 1,
"value": "1"
"options": {
"0": {
"text": "Offline"
},
"1": {
"text": "Online"
}
},
"type": "value"
},
{
"from": "",
"id": 1,
"text": "Offline",
"to": "",
"type": 1,
"value": "0"
},
{
"from": "",
"id": 2,
"text": "Unknown",
"to": "",
"type": 1,
"value": "null"
"options": {
"match": "null",
"result": {
"text": "Unknown"
}
},
"type": "special"
}
],
"thresholds": {
@@ -139,9 +98,13 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"expr": "qbittorrent_up",
"format": "time_series",
"instant": false,
@@ -150,41 +113,31 @@
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Qbittorrent Status",
"transformations": [],
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [
{
"from": "",
"id": 0,
"text": "Connected",
"to": "",
"type": 1,
"value": "1"
},
{
"from": "",
"id": 1,
"text": "Disconnected",
"to": "",
"type": 1,
"value": "0"
},
{
"from": "",
"id": 2,
"text": "Firewalled",
"to": "",
"type": 1,
"value": "-1"
"options": {
"0": {
"text": "Disconnected"
},
"1": {
"text": "Connected"
},
"-1": {
"text": "Firewalled"
}
},
"type": "value"
}
],
"thresholds": {
@@ -228,7 +181,7 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "qbittorrent_connected - qbittorrent_firewalled",
@@ -239,33 +192,28 @@
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Bittorrent network",
"transformations": [],
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [
{
"from": "",
"id": 1,
"text": "No DHT",
"to": "",
"type": 1,
"value": "0"
},
{
"from": "",
"id": 2,
"text": "Firewalled",
"to": "",
"type": 1,
"value": "-1"
"options": {
"0": {
"text": "No DHT"
},
"-1": {
"text": "Firewalled"
}
},
"type": "value"
}
],
"thresholds": {
@@ -309,7 +257,7 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "qbittorrent_dht_nodes",
@@ -319,17 +267,17 @@
"refId": "B"
}
],
"timeFrom": null,
"timeShift": null,
"title": "DHT nodes",
"transformations": [],
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
@@ -340,7 +288,7 @@
}
]
},
"unit": "decbytes"
"unit": "bytes"
},
"overrides": []
},
@@ -365,26 +313,31 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "increase(qbittorrent_dl_info_data_total[100y])",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"editorMode": "code",
"expr": "sum by (app) (qbittorrent_dl_info_data_total)",
"instant": false,
"interval": "",
"legendFormat": "",
"legendFormat": " ",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Data downloaded",
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
@@ -395,7 +348,7 @@
}
]
},
"unit": "decbytes"
"unit": "bytes"
},
"overrides": []
},
@@ -420,26 +373,31 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "increase(qbittorrent_up_info_data_total[100y])",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"editorMode": "code",
"expr": "sum by (app) (qbittorrent_up_info_data_total)",
"instant": false,
"interval": "",
"legendFormat": "",
"legendFormat": " ",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Data uploaded",
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
@@ -475,26 +433,31 @@
},
"textMode": "auto"
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "increase(qbittorrent_up_info_data_total[100y]) / increase(qbittorrent_dl_info_data_total[100y])",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"editorMode": "code",
"expr": "(sum by (app) (qbittorrent_up_info_data_total)) / (sum by (app) (qbittorrent_dl_info_data_total))",
"instant": false,
"interval": "",
"legendFormat": "",
"legendFormat": "{{label_name}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Accumulated ratio",
"type": "stat"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"decimals": 1,
"mappings": [],
"max": 48000000,
@@ -535,36 +498,28 @@
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "rate(qbittorrent_dl_info_data_total[1m])",
"expr": "rate(qbittorrent_dl_info_data_total[2m])",
"instant": false,
"interval": "",
"legendFormat": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Download speed",
"type": "gauge"
},
{
"datasource": "${DS_PROMETHEUS}",
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fieldConfig": {
"defaults": {
"custom": {},
"decimals": 1,
"mappings": [
{
"from": "",
"id": 1,
"text": "",
"to": "",
"type": 1
}
],
"mappings": [],
"max": 18000000,
"min": 0,
"thresholds": {
@@ -603,10 +558,10 @@
"showThresholdLabels": false,
"showThresholdMarkers": true
},
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"targets": [
{
"expr": "rate(qbittorrent_up_info_data_total[1m])",
"expr": "rate(qbittorrent_up_info_data_total[2m])",
"hide": false,
"instant": false,
"interval": "",
@@ -614,8 +569,6 @@
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Upload speed",
"type": "gauge"
},
@@ -624,12 +577,9 @@
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "${DS_PROMETHEUS}",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fill": 1,
"fillGradient": 0,
@@ -657,7 +607,7 @@
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"pointradius": 2,
"points": false,
"renderer": "flot",
@@ -667,23 +617,21 @@
"steppedLine": false,
"targets": [
{
"expr": "rate(qbittorrent_dl_info_data_total[1m])",
"expr": "rate(qbittorrent_dl_info_data_total[2m])",
"interval": "",
"legendFormat": "Download",
"refId": "A"
},
{
"expr": "rate(qbittorrent_up_info_data_total[1m])",
"expr": "rate(qbittorrent_up_info_data_total[2m])",
"interval": "",
"legendFormat": "Upload",
"refId": "B"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Transfer ratio",
"title": "Transfer Rates",
"tooltip": {
"shared": true,
"sort": 0,
@@ -691,47 +639,34 @@
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "binBps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "binBps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
"align": false
}
},
{
"aliasColors": {},
"bars": true,
"cacheTimeout": null,
"dashLength": 10,
"dashes": false,
"datasource": "${DS_PROMETHEUS}",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"fill": 1,
"fillGradient": 0,
@@ -743,7 +678,6 @@
},
"hiddenSeries": false,
"id": 11,
"interval": null,
"legend": {
"alignAsTable": true,
"avg": false,
@@ -763,7 +697,7 @@
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.2.2",
"pluginVersion": "9.4.3",
"pointradius": 2,
"points": false,
"renderer": "flot",
@@ -780,9 +714,7 @@
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Torrents by status",
"tooltip": {
"shared": true,
@@ -791,65 +723,47 @@
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
"align": false
}
},
{
"aliasColors": {},
"breakPoint": "50%",
"cacheTimeout": null,
"combine": {
"label": "Others",
"threshold": 0
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"datasource": "${DS_PROMETHEUS}",
"fieldConfig": {
"defaults": {
"custom": {
"align": null,
"filterable": false
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": []
},
"overrides": []
},
"fontSize": "80%",
"format": "short",
"gridPos": {
"h": 10,
"w": 10,
@@ -857,19 +771,27 @@
"y": 14
},
"id": 12,
"interval": null,
"legend": {
"percentage": true,
"show": true,
"sideWidth": null,
"values": true
},
"legendType": "Right side",
"links": [],
"nullPointMode": "connected",
"pieType": "donut",
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "7.2.0",
"strokeWidth": 1,
"targets": [
{
"expr": "sum(qbittorrent_torrents_count{category=~\"${categories}\",status!=\"complete\"}) by (category)",
@@ -878,23 +800,28 @@
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Torrents by categories",
"type": "grafana-piechart-panel",
"valueName": "current"
"type": "piechart"
}
],
"refresh": "30s",
"schemaVersion": 26,
"refresh": "10s",
"revision": 1,
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": ".*",
"current": {},
"datasource": "${DS_PROMETHEUS}",
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "${data_source}"
},
"definition": "label_values(qbittorrent_torrents_count, category)",
"hide": 0,
"includeAll": true,
@@ -902,26 +829,48 @@
"multi": true,
"name": "categories",
"options": [],
"query": "label_values(qbittorrent_torrents_count, category)",
"query": {
"query": "label_values(qbittorrent_torrents_count, category)",
"refId": "Prometheus-categories-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"current": {
"selected": true,
"text": "Prometheus",
"value": "Prometheus"
},
"hide": 0,
"includeAll": false,
"label": "Data Source",
"multi": false,
"name": "data_source",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-5m",
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Qbittorrent",
"uid": "eKyTETFMk",
"version": 40
"version": 1,
"weekStart": ""
}

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

284
pdm.lock generated Normal file
View File

@@ -0,0 +1,284 @@
# This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4"
content_hash = "sha256:474c773ee86217d652b5fc82f921ce2fbb513b244d50fbe82b74ea9e47dad96e"
[[package]]
name = "black"
version = "23.11.0"
requires_python = ">=3.8"
summary = "The uncompromising code formatter."
dependencies = [
"click>=8.0.0",
"mypy-extensions>=0.4.3",
"packaging>=22.0",
"pathspec>=0.9.0",
"platformdirs>=2",
]
files = [
{file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"},
{file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"},
{file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"},
{file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"},
{file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"},
{file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"},
]
[[package]]
name = "certifi"
version = "2023.7.22"
requires_python = ">=3.6"
summary = "Python package for providing Mozilla's CA Bundle."
files = [
{file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
{file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
]
[[package]]
name = "charset-normalizer"
version = "3.2.0"
requires_python = ">=3.7.0"
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
files = [
{file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"},
{file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"},
{file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"},
{file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"},
{file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"},
{file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"},
{file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"},
{file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"},
{file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"},
{file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"},
{file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"},
{file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"},
{file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"},
{file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"},
{file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"},
{file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"},
{file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"},
]
[[package]]
name = "click"
version = "8.1.7"
requires_python = ">=3.7"
summary = "Composable command line interface toolkit"
dependencies = [
"colorama; platform_system == \"Windows\"",
]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[[package]]
name = "colorama"
version = "0.4.6"
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
summary = "Cross-platform colored terminal text."
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.3.2"
requires_python = ">=3.8"
summary = "Code coverage measurement for Python"
files = [
{file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"},
{file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"},
{file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"},
{file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"},
{file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"},
{file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"},
{file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"},
{file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"},
{file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"},
{file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"},
]
[[package]]
name = "idna"
version = "3.4"
requires_python = ">=3.5"
summary = "Internationalized Domain Names in Applications (IDNA)"
files = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
[[package]]
name = "iniconfig"
version = "2.0.0"
requires_python = ">=3.7"
summary = "brain-dead simple config-ini parsing"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "isort"
version = "5.12.0"
requires_python = ">=3.8.0"
summary = "A Python utility / library to sort Python imports."
files = [
{file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
{file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
requires_python = ">=3.5"
summary = "Type system extensions for programs checked with the mypy type checker."
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.1"
requires_python = ">=3.7"
summary = "Core utilities for Python packages"
files = [
{file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
{file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
]
[[package]]
name = "pathspec"
version = "0.11.2"
requires_python = ">=3.7"
summary = "Utility library for gitignore style pattern matching of file paths."
files = [
{file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"},
{file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
]
[[package]]
name = "platformdirs"
version = "4.0.0"
requires_python = ">=3.7"
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
files = [
{file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
]
[[package]]
name = "pluggy"
version = "1.3.0"
requires_python = ">=3.8"
summary = "plugin and hook calling mechanisms for python"
files = [
{file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
{file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
]
[[package]]
name = "prometheus-client"
version = "0.17.1"
requires_python = ">=3.6"
summary = "Python client for the Prometheus monitoring system."
files = [
{file = "prometheus_client-0.17.1-py3-none-any.whl", hash = "sha256:e537f37160f6807b8202a6fc4764cdd19bac5480ddd3e0d463c3002b34462101"},
{file = "prometheus_client-0.17.1.tar.gz", hash = "sha256:21e674f39831ae3f8acde238afd9a27a37d0d2fb5a28ea094f0ce25d2cbf2091"},
]
[[package]]
name = "pytest"
version = "7.4.3"
requires_python = ">=3.7"
summary = "pytest: simple powerful testing with Python"
dependencies = [
"colorama; sys_platform == \"win32\"",
"iniconfig",
"packaging",
"pluggy<2.0,>=0.12",
]
files = [
{file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
{file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
]
[[package]]
name = "python-json-logger"
version = "2.0.7"
requires_python = ">=3.6"
summary = "A python library adding a json log formatter"
files = [
{file = "python-json-logger-2.0.7.tar.gz", hash = "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c"},
{file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"},
]
[[package]]
name = "qbittorrent-api"
version = "2023.9.53"
summary = "Python client for qBittorrent v4.1+ Web API."
dependencies = [
"packaging",
"requests>=2.16.0",
"six",
"urllib3>=1.24.2",
]
files = [
{file = "qbittorrent-api-2023.9.53.tar.gz", hash = "sha256:fead1b2f55b1227ea088ea7d90b5022d94694bfd9dd9176beb5ad1c195d044ff"},
{file = "qbittorrent_api-2023.9.53-py2.py3-none-any.whl", hash = "sha256:963ae59d16a9c4a9aa1714fb7f6799539dc2693136cdc0e377daab3612ca775a"},
]
[[package]]
name = "requests"
version = "2.31.0"
requires_python = ">=3.7"
summary = "Python HTTP for Humans."
dependencies = [
"certifi>=2017.4.17",
"charset-normalizer<4,>=2",
"idna<4,>=2.5",
"urllib3<3,>=1.21.1",
]
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
]
[[package]]
name = "six"
version = "1.16.0"
requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
summary = "Python 2 and 3 compatibility utilities"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "urllib3"
version = "2.0.5"
requires_python = ">=3.7"
summary = "HTTP library with thread-safe connection pooling, file post, and more."
files = [
{file = "urllib3-2.0.5-py3-none-any.whl", hash = "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e"},
{file = "urllib3-2.0.5.tar.gz", hash = "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594"},
]

36
pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[project]
name = "prometheus-qbittorrent-exporter"
version = "1.4.0"
description = "Prometheus exporter for qbittorrent"
authors = [
{name = "Esteban Sanchez", email = "esteban.sanchez@gmail.com"},
]
dependencies = [
"prometheus-client>=0.17.1",
"python-json-logger>=2.0.7",
"qbittorrent-api>=2023.9.53",
]
requires-python = ">=3.11"
readme = "README.md"
keywords = ["prometheus", "qbittorrent"]
license = {text = "GPL-3.0"}
classifiers = []
[project.urls]
Homepage = "https://github.com/esanchezm/prometheus-qbittorrent-exporter"
Downloads = "https://github.com/esanchezm/prometheus-qbittorrent-exporter/archive/1.4.0.tar.gz"
[project.scripts]
qbittorrent-exporter = "qbittorrent_exporter.exporter:main"
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pdm.dev-dependencies]
dev = [
"pytest>=7.4.3",
"isort>=5.12.0",
"black>=23.11.0",
"coverage>=7.3.2",
]

View File

@@ -1,214 +1,309 @@
import time
import os
import sys
import signal
import faulthandler
from attrdict import AttrDict
from qbittorrentapi import Client, TorrentStates
from qbittorrentapi.exceptions import APIConnectionError
from prometheus_client import start_http_server
from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily, REGISTRY
import logging
from pythonjsonlogger import jsonlogger
import os
import signal
import sys
import time
from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import Any, Iterable
from prometheus_client import start_http_server
from prometheus_client.core import REGISTRY, CounterMetricFamily, GaugeMetricFamily
from pythonjsonlogger import jsonlogger
from qbittorrentapi import Client, TorrentStates
# Enable dumps on stderr in case of segfault
faulthandler.enable()
logger = None
logger = logging.getLogger()
class QbittorrentMetricsCollector():
TORRENT_STATUSES = [
"downloading",
"uploading",
"complete",
"checking",
"errored",
"paused",
]
class MetricType(StrEnum):
"""
Represents possible metric types (used in this project).
"""
def __init__(self, config):
GAUGE = auto()
COUNTER = auto()
@dataclass
class Metric:
"""
Contains data and metadata about a single counter or gauge.
"""
name: str
value: Any
labels: dict[str, str] = field(default_factory=lambda: {}) # Default to empty dict
help_text: str = ""
metric_type: MetricType = MetricType.GAUGE
class QbittorrentMetricsCollector:
def __init__(self, config: dict) -> None:
self.config = config
self.torrents = None
self.client = Client(
host=config["host"],
port=config["port"],
username=config["username"],
password=config["password"],
VERIFY_WEBUI_CERTIFICATE=config["verify_webui_certificate"],
)
def collect(self):
try:
self.torrents = self.client.torrents.info()
except Exception as e:
logger.error(f"Couldn't get server info: {e}")
return None
metrics = self.get_qbittorrent_metrics()
def collect(self) -> Iterable[GaugeMetricFamily | CounterMetricFamily]:
"""
Yields Prometheus gauges and counters from metrics collected from qbittorrent.
"""
metrics: list[Metric] = self.get_qbittorrent_metrics()
for metric in metrics:
name = metric["name"]
value = metric["value"]
help_text = metric.get("help", "")
labels = metric.get("labels", {})
metric_type = metric.get("type", "gauge")
if metric_type == "counter":
prom_metric = CounterMetricFamily(name, help_text, labels=labels.keys())
if metric.metric_type == MetricType.COUNTER:
prom_metric = CounterMetricFamily(
metric.name, metric.help_text, labels=list(metric.labels.keys())
)
else:
prom_metric = GaugeMetricFamily(name, help_text, labels=labels.keys())
prom_metric.add_metric(value=value, labels=labels.values())
prom_metric = GaugeMetricFamily(
metric.name, metric.help_text, labels=list(metric.labels.keys())
)
prom_metric.add_metric(
value=metric.value, labels=list(metric.labels.values())
)
yield prom_metric
def get_qbittorrent_metrics(self):
metrics = []
metrics.extend(self.get_qbittorrent_status_metrics())
metrics.extend(self.get_qbittorrent_torrent_tags_metrics())
def get_qbittorrent_metrics(self) -> list[Metric]:
"""
Calls and combines qbittorrent state metrics with torrent metrics.
"""
metrics: list[Metric] = []
metrics.extend(self._get_qbittorrent_status_metrics())
metrics.extend(self._get_qbittorrent_torrent_tags_metrics())
return metrics
def get_qbittorrent_status_metrics(self):
response = {}
version = ""
def _get_qbittorrent_status_metrics(self) -> list[Metric]:
"""
Returns metrics about the state of the qbittorrent server.
"""
response: dict[str, Any] = {}
version: str = ""
# Fetch data from API
try:
response = self.client.transfer.info
version = self.client.app.version
self.torrents = self.client.torrents.info()
except APIConnectionError as e:
logger.error(f"Couldn't get server info: {e.error_message}")
except Exception:
logger.error(f"Couldn't get server info")
except Exception as e:
logger.error(f"Couldn't get server info: {e}")
return [
{
"name": f"{self.config['metrics_prefix']}_up",
"value": bool(response),
"labels": {"version": version},
"help": "Whether if server is alive or not",
},
{
"name": f"{self.config['metrics_prefix']}_connected",
"value": response.get("connection_status", "") == "connected",
"help": "Whether if server is connected or not",
},
{
"name": f"{self.config['metrics_prefix']}_firewalled",
"value": response.get("connection_status", "") == "firewalled",
"help": "Whether if server is under a firewall or not",
},
{
"name": f"{self.config['metrics_prefix']}_dht_nodes",
"value": response.get("dht_nodes", 0),
"help": "DHT nodes connected to",
},
{
"name": f"{self.config['metrics_prefix']}_dl_info_data",
"value": response.get("dl_info_data", 0),
"help": "Data downloaded this session (bytes)",
"type": "counter"
},
{
"name": f"{self.config['metrics_prefix']}_up_info_data",
"value": response.get("up_info_data", 0),
"help": "Data uploaded this session (bytes)",
"type": "counter"
},
Metric(
name=f"{self.config['metrics_prefix']}_up",
value=bool(response),
labels={"version": version},
help_text=(
"Whether the qBittorrent server is answering requests from this"
" exporter. A `version` label with the server version is added."
),
),
Metric(
name=f"{self.config['metrics_prefix']}_connected",
value=response.get("connection_status", "") == "connected",
labels={}, # no labels in the example
help_text=(
"Whether the qBittorrent server is connected to the Bittorrent"
" network."
),
),
Metric(
name=f"{self.config['metrics_prefix']}_firewalled",
value=response.get("connection_status", "") == "firewalled",
labels={}, # no labels in the example
help_text=(
"Whether the qBittorrent server is connected to the Bittorrent"
" network but is behind a firewall."
),
),
Metric(
name=f"{self.config['metrics_prefix']}_dht_nodes",
value=response.get("dht_nodes", 0),
labels={}, # no labels in the example
help_text="Number of DHT nodes connected to.",
),
Metric(
name=f"{self.config['metrics_prefix']}_dl_info_data",
value=response.get("dl_info_data", 0),
labels={}, # no labels in the example
help_text="Data downloaded since the server started, in bytes.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_up_info_data",
value=response.get("up_info_data", 0),
labels={}, # no labels in the example
help_text="Data uploaded since the server started, in bytes.",
metric_type=MetricType.COUNTER,
),
]
def get_qbittorrent_torrent_tags_metrics(self):
def _fetch_categories(self) -> dict:
"""Fetches all categories in use from qbittorrent."""
try:
categories = self.client.torrent_categories.categories
categories = dict(self.client.torrent_categories.categories)
for key, value in categories.items():
categories[key] = dict(value) # type: ignore
return categories
except Exception as e:
logger.error(f"Couldn't fetch categories: {e}")
return {}
def _fetch_torrents(self) -> list[dict]:
"""Fetches torrents from qbittorrent"""
try:
return [dict(_attr_dict) for _attr_dict in self.client.torrents.info()]
except Exception as e:
logger.error(f"Couldn't fetch torrents: {e}")
return []
if not self.torrents:
return []
def _filter_torrents_by_category(
self, category: str, torrents: list[dict]
) -> list[dict]:
"""Filters torrents by the given category."""
return [
torrent
for torrent in torrents
if torrent["category"] == category
or (category == "Uncategorized" and torrent["category"] == "")
]
def _filter_torrents_by_state(
self, state: TorrentStates, torrents: list[dict]
) -> list[dict]:
"""Filters torrents by the given state."""
return [torrent for torrent in torrents if torrent["state"] == state.value]
def _construct_metric(self, state: str, category: str, count: int) -> Metric:
"""Constructs and returns a metric object with a torrent count and appropriate
labels."""
return Metric(
name=f"{self.config['metrics_prefix']}_torrents_count",
value=count,
labels={
"status": state,
"category": category,
},
help_text=f"Number of torrents in status {state} under category {category}",
)
def _get_qbittorrent_torrent_tags_metrics(self) -> list[Metric]:
categories = self._fetch_categories()
torrents = self._fetch_torrents()
metrics: list[Metric] = []
categories["Uncategorized"] = {"name": "Uncategorized", "savePath": ""}
metrics = []
categories.Uncategorized = AttrDict({'name': 'Uncategorized', 'savePath': ''})
for category in categories:
category_torrents = [t for t in self.torrents if t['category'] == category or (category == "Uncategorized" and t['category'] == "")]
for status in self.TORRENT_STATUSES:
status_prop = f"is_{status}"
status_torrents = [
t for t in category_torrents if getattr(TorrentStates, status_prop).fget(TorrentStates(t['state']))
]
metrics.append({
"name": f"{self.config['metrics_prefix']}_torrents_count",
"value": len(status_torrents),
"labels": {
"status": status,
"category": category,
},
"help": f"Number of torrents in status {status} under category {category}"
})
category_torrents = self._filter_torrents_by_category(category, torrents)
for state in TorrentStates:
state_torrents = self._filter_torrents_by_state(
state, category_torrents
)
metric = self._construct_metric(
state.value, category, len(state_torrents)
)
metrics.append(metric)
return metrics
class SignalHandler():
class ShutdownSignalHandler:
def __init__(self):
self.shutdown = False
self.shutdown_count: int = 0
# Register signal handler
signal.signal(signal.SIGINT, self._on_signal_received)
signal.signal(signal.SIGTERM, self._on_signal_received)
def is_shutting_down(self):
return self.shutdown
return self.shutdown_count > 0
def _on_signal_received(self, signal, frame):
if self.shutdown_count > 1:
logger.warn("Forcibly killing exporter")
sys.exit(1)
logger.info("Exporter is shutting down")
self.shutdown = True
self.shutdown_count += 1
def _get_config_value(key: str, default: str = "") -> str:
input_path = os.environ.get("FILE__" + key, None)
if input_path is not None:
try:
with open(input_path, "r") as input_file:
return input_file.read().strip()
except IOError as e:
logger.error(f"Unable to read value for {key} from {input_path}: {str(e)}")
return os.environ.get(key, default)
def get_config() -> dict:
"""Loads all config values."""
return {
"host": _get_config_value("QBITTORRENT_HOST", ""),
"port": _get_config_value("QBITTORRENT_PORT", ""),
"username": _get_config_value("QBITTORRENT_USER", ""),
"password": _get_config_value("QBITTORRENT_PASS", ""),
"exporter_port": int(_get_config_value("EXPORTER_PORT", "8000")),
"log_level": _get_config_value("EXPORTER_LOG_LEVEL", "INFO"),
"metrics_prefix": _get_config_value("METRICS_PREFIX", "qbittorrent"),
"verify_webui_certificate": (
_get_config_value("VERIFY_WEBUI_CERTIFICATE", "True") == "True"
),
}
def main():
config = {
"host": os.environ.get("QBITTORRENT_HOST", ""),
"port": os.environ.get("QBITTORRENT_PORT", ""),
"username": os.environ.get("QBITTORRENT_USER", ""),
"password": os.environ.get("QBITTORRENT_PASS", ""),
"exporter_port": int(os.environ.get("EXPORTER_PORT", "8000")),
"log_level": os.environ.get("EXPORTER_LOG_LEVEL", "INFO"),
"metrics_prefix": os.environ.get("METRICS_PREFIX", "qbittorrent"),
}
# Register signal handler
signal_handler = SignalHandler()
# Init logger
# Init logger so it can be used
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
"%(asctime) %(levelname) %(message)",
datefmt="%Y-%m-%d %H:%M:%S"
"%(asctime) %(levelname) %(message)", datefmt="%Y-%m-%d %H:%M:%S"
)
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel("INFO") # default until config is loaded
config = get_config()
# set level once config has been loaded
logger.setLevel(config["log_level"])
# Register signal handler
signal_handler = ShutdownSignalHandler()
if not config["host"]:
logger.error("No host specified, please set QBITTORRENT_HOST environment variable")
logger.error(
"No host specified, please set QBITTORRENT_HOST environment variable"
)
sys.exit(1)
if not config["port"]:
logger.error("No post specified, please set QBITTORRENT_PORT environment variable")
logger.error(
"No port specified, please set QBITTORRENT_PORT environment variable"
)
sys.exit(1)
# Register our custom collector
logger.info("Exporter is starting up")
REGISTRY.register(QbittorrentMetricsCollector(config))
REGISTRY.register(QbittorrentMetricsCollector(config)) # type: ignore
# Start server
start_http_server(config["exporter_port"])
logger.info(
f"Exporter listening on port {config['exporter_port']}"
)
logger.info(f"Exporter listening on port {config['exporter_port']}")
while not signal_handler.is_shutting_down():
time.sleep(1)
logger.info("Exporter has shutdown")
if __name__ == "__main__":
main()

View File

@@ -1,2 +0,0 @@
[metadata]
description-file = README.md

View File

@@ -1,26 +0,0 @@
from setuptools import setup
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name='prometheus-qbittorrent-exporter',
packages=['qbittorrent_exporter'],
version='1.1.1',
long_description=long_description,
long_description_content_type="text/markdown",
description='Prometheus exporter for qbittorrent',
author='Esteban Sanchez',
author_email='esteban.sanchez@gmail.com',
url='https://github.com/esanchezm/prometheus-qbittorrent-exporter',
download_url='https://github.com/esanchezm/prometheus-qbittorrent-exporter/archive/1.1.0.tar.gz',
keywords=['prometheus', 'qbittorrent'],
classifiers=[],
python_requires='>=3',
install_requires=['qbittorrent-api==2021.3.18', 'prometheus_client==0.8.0', 'python-json-logger==0.1.5'],
entry_points={
'console_scripts': [
'qbittorrent-exporter=qbittorrent_exporter.exporter:main',
]
}
)

0
tests/__init__.py Normal file
View File

276
tests/exporter_test.py Normal file
View File

@@ -0,0 +1,276 @@
import unittest
from unittest.mock import MagicMock, patch
from prometheus_client.metrics_core import CounterMetricFamily, GaugeMetricFamily
from qbittorrent_exporter.exporter import (
Metric,
MetricType,
QbittorrentMetricsCollector,
)
from qbittorrentapi import TorrentStates
class TestQbittorrentMetricsCollector(unittest.TestCase):
@patch("qbittorrent_exporter.exporter.Client")
def setUp(self, mock_client):
self.mock_client = mock_client
self.config = {
"host": "localhost",
"port": "8080",
"username": "user",
"password": "pass",
"verify_webui_certificate": False,
"metrics_prefix": "qbittorrent",
}
self.torrentsState = [
{"name": "Torrent DOWNLOADING 1", "state": TorrentStates.DOWNLOADING},
{"name": "Torrent UPLOADING 1", "state": TorrentStates.UPLOADING},
{"name": "Torrent DOWNLOADING 2", "state": TorrentStates.DOWNLOADING},
{"name": "Torrent UPLOADING 2", "state": TorrentStates.UPLOADING},
]
self.torrentsCategories = [
{"name": "Torrent Movies 1", "category": "Movies"},
{"name": "Torrent Music 1", "category": "Music"},
{"name": "Torrent Movies 2", "category": "Movies"},
{"name": "Torrent unknown", "category": ""},
{"name": "Torrent Music 2", "category": "Music"},
{"name": "Torrent Uncategorized 1", "category": "Uncategorized"},
]
self.collector = QbittorrentMetricsCollector(self.config)
def test_init(self):
self.assertEqual(self.collector.config, self.config)
self.mock_client.assert_called_once_with(
host=self.config["host"],
port=self.config["port"],
username=self.config["username"],
password=self.config["password"],
VERIFY_WEBUI_CERTIFICATE=self.config["verify_webui_certificate"],
)
def test_collect_gauge(self):
mock_metric = Metric(
name="test_gauge",
metric_type=MetricType.GAUGE,
help_text="Test Gauge",
labels={"label1": "value1"},
value=10,
)
self.collector.get_qbittorrent_metrics = MagicMock(return_value=[mock_metric])
result = next(self.collector.collect())
self.assertIsInstance(result, GaugeMetricFamily)
self.assertEqual(result.name, "test_gauge")
self.assertEqual(result.documentation, "Test Gauge")
self.assertEqual(result.samples[0].labels, {"label1": "value1"})
self.assertEqual(result.samples[0].value, 10)
def test_collect_counter(self):
mock_metric = Metric(
name="test_counter",
metric_type=MetricType.COUNTER,
help_text="Test Counter",
labels={"label2": "value2"},
value=230,
)
self.collector.get_qbittorrent_metrics = MagicMock(return_value=[mock_metric])
result = next(self.collector.collect())
self.assertIsInstance(result, CounterMetricFamily)
self.assertEqual(result.name, "test_counter")
self.assertEqual(result.documentation, "Test Counter")
self.assertEqual(result.samples[0].labels, {"label2": "value2"})
self.assertEqual(result.samples[0].value, 230)
def test_get_qbittorrent_metrics(self):
metrics = self.collector.get_qbittorrent_metrics()
self.assertNotEqual(len(metrics), 0)
def test_fetch_categories(self):
# Mock the client.torrent_categories.categories attribute
self.collector.client.torrent_categories.categories = {
"category1": {"name": "Category 1"},
"category2": {"name": "Category 2"},
"category3": {"name": "Category 3"},
}
categories = self.collector._fetch_categories()
self.assertIsInstance(categories, dict)
self.assertNotEqual(len(categories), 0)
self.assertEqual(categories["category1"]["name"], "Category 1")
self.assertEqual(categories["category2"]["name"], "Category 2")
self.assertEqual(categories["category3"]["name"], "Category 3")
def test_fetch_categories_exception(self):
self.collector.client.torrent_categories.categories = Exception(
"Error fetching categories"
)
categories = self.collector._fetch_categories()
self.assertEqual(categories, {})
def test_fetch_torrents_success(self):
# Mock the return value of self.client.torrents.info()
self.collector.client.torrents.info.return_value = [
{"name": "Torrent 1", "size": 100},
{"name": "Torrent 2", "size": 200},
{"name": "Torrent 3", "size": 300},
]
expected_result = [
{"name": "Torrent 1", "size": 100},
{"name": "Torrent 2", "size": 200},
{"name": "Torrent 3", "size": 300},
]
result = self.collector._fetch_torrents()
self.assertEqual(result, expected_result)
def test_fetch_torrents_exception(self):
# Mock an exception being raised by self.client.torrents.info()
self.collector.client.torrents.info.side_effect = Exception("Connection error")
expected_result = []
result = self.collector._fetch_torrents()
self.assertEqual(result, expected_result)
def test_filter_torrents_by_state(self):
expected = [
{"name": "Torrent DOWNLOADING 1", "state": TorrentStates.DOWNLOADING},
{"name": "Torrent DOWNLOADING 2", "state": TorrentStates.DOWNLOADING},
]
result = self.collector._filter_torrents_by_state(
TorrentStates.DOWNLOADING, self.torrentsState
)
self.assertEqual(result, expected)
expected = [
{"name": "Torrent UPLOADING 1", "state": TorrentStates.UPLOADING},
{"name": "Torrent UPLOADING 2", "state": TorrentStates.UPLOADING},
]
result = self.collector._filter_torrents_by_state(
TorrentStates.UPLOADING, self.torrentsState
)
self.assertEqual(result, expected)
expected = []
result = self.collector._filter_torrents_by_state(
TorrentStates.ERROR, self.torrentsState
)
self.assertEqual(result, expected)
def test_filter_torrents_by_category(self):
expected_result = [
{"name": "Torrent Movies 1", "category": "Movies"},
{"name": "Torrent Movies 2", "category": "Movies"},
]
result = self.collector._filter_torrents_by_category(
"Movies", self.torrentsCategories
)
self.assertEqual(result, expected_result)
expected_result = [
{"name": "Torrent unknown", "category": ""},
{"name": "Torrent Uncategorized 1", "category": "Uncategorized"},
]
result = self.collector._filter_torrents_by_category(
"Uncategorized", self.torrentsCategories
)
self.assertEqual(result, expected_result)
expected_result = []
result = self.collector._filter_torrents_by_category(
"Books", self.torrentsCategories
)
self.assertEqual(result, expected_result)
def test_construct_metric_with_valid_state_and_category(self):
state = "downloading"
category = "movies"
count = 10
metric = self.collector._construct_metric(state, category, count)
self.assertEqual(metric.name, "qbittorrent_torrents_count")
self.assertEqual(metric.value, count)
self.assertEqual(metric.labels["status"], state)
self.assertEqual(metric.labels["category"], category)
self.assertEqual(
metric.help_text,
f"Number of torrents in status {state} under category {category}",
)
def test_construct_metric_with_empty_state_and_category(self):
state = ""
category = ""
count = 5
metric = self.collector._construct_metric(state, category, count)
self.assertEqual(metric.name, "qbittorrent_torrents_count")
self.assertEqual(metric.value, count)
self.assertEqual(metric.labels["status"], state)
self.assertEqual(metric.labels["category"], category)
self.assertEqual(
metric.help_text, "Number of torrents in status under category "
)
def test_get_qbittorrent_status_metrics(self):
self.collector.client.transfer.info = {"connection_status": "connected"}
self.collector.client.app.version = "1.2.3"
expected_metrics = [
Metric(
name="qbittorrent_up",
value=True,
labels={"version": "1.2.3"},
help_text=(
"Whether the qBittorrent server is answering requests from this"
" exporter. A `version` label with the server version is added."
),
),
Metric(
name="qbittorrent_connected",
value=True,
labels={},
help_text=(
"Whether the qBittorrent server is connected to the Bittorrent"
" network."
),
),
Metric(
name="qbittorrent_firewalled",
value=False,
labels={},
help_text=(
"Whether the qBittorrent server is connected to the Bittorrent"
" network but is behind a firewall."
),
),
Metric(
name="qbittorrent_dht_nodes",
value=0,
labels={},
help_text="Number of DHT nodes connected to.",
),
Metric(
name="qbittorrent_dl_info_data",
value=0,
labels={},
help_text="Data downloaded since the server started, in bytes.",
metric_type=MetricType.COUNTER,
),
Metric(
name="qbittorrent_up_info_data",
value=0,
labels={},
help_text="Data uploaded since the server started, in bytes.",
metric_type=MetricType.COUNTER,
),
]
metrics = self.collector._get_qbittorrent_status_metrics()
self.assertEqual(metrics, expected_metrics)

13
tests/metric_test.py Normal file
View File

@@ -0,0 +1,13 @@
import unittest
from qbittorrent_exporter.exporter import Metric, MetricType
class TestMetric(unittest.TestCase):
def test_metric_initialization(self):
metric = Metric(name="test_metric", value=10)
self.assertEqual(metric.name, "test_metric")
self.assertEqual(metric.value, 10)
self.assertEqual(metric.labels, {})
self.assertEqual(metric.help_text, "")
self.assertEqual(metric.metric_type, MetricType.GAUGE)