Compare commits

..

No commits in common. "master" and "1.1.2" have entirely different histories.

27 changed files with 593 additions and 2729 deletions

View File

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

View File

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

View File

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

View File

@ -11,63 +11,30 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout GitHub Action'
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v1
- 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: 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: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push docker to DockerHub
uses: docker/build-push-action@v5
- name: Build and push docker
uses: docker/build-push-action@v2
with:
push: true
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 }}
tags: esanchezm/prometheus-qbittorrent-exporter:latest,esanchezm/prometheus-qbittorrent-exporter:${{ steps.extract_branch.outputs.tag }}

View File

@ -1,59 +0,0 @@
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.12"]
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.12'
python-version: '3.6'
- 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 -m build
python setup.py sdist
twine upload dist/*

10
.gitignore vendored
View File

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

View File

@ -1,29 +0,0 @@
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,4 +1,8 @@
FROM python:3.12-alpine
FROM alpine:3.11
# Installing required packages
RUN apk add --update --no-cache \
python3
# Install package
WORKDIR /code

View File

@ -1,12 +1,7 @@
# 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
@ -25,11 +20,7 @@ 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 \
ghcr.io/esanchezm/prometheus-qbittorrent-exporter
docker run -e QBITTORRENT_PORT=8080 -e QBITTORRENT_HOST=myserver.local -p 8000:8000 esanchezm/prometheus-qbittorrent-exporter
```
Add this to your prometheus.yml
```
@ -39,16 +30,15 @@ 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 |
| `VERIFY_WEBUI_CERTIFICATE` | `True` | Whether to verify SSL certificate when connecting to the qbittorrent server. Any other value but `True` will disable the verification |
| 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 |
## Metrics
@ -58,12 +48,12 @@ These are the metrics this program exports, assuming the `METRICS_PREFIX` is `qb
| Metric name | Type | Description |
| --------------------------------------------------- | -------- | ---------------- |
| `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_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_torrents_count` | gauge | Number of torrents for each `category` and `status`. Example: `qbittorrent_torrents_count{category="movies",status="downloading"}`|
## Screenshot

View File

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

View File

@ -1,39 +0,0 @@
name: prom-qb-alltime
services:
esanchezm:
cpu_shares: 90
command: []
deploy:
resources:
limits:
memory: 7943M
environment:
- QBITTORRENT_HOST=192.168.1.58
- QBITTORRENT_PASS=Cp3mMdP!#
- QBITTORRENT_PORT=8188
- QBITTORRENT_USER=noham
image: prom-qb-alltime
labels:
icon: https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/master/logo.png
ports:
- target: 8000
published: "9101"
protocol: tcp
restart: unless-stopped
volumes: []
devices: []
cap_add: []
network_mode: bridge
privileged: false
container_name: ""
x-casaos:
author: self
category: self
hostname: ""
icon: https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/master/logo.png
index: /metrics
port_map: "9101"
scheme: http
store_app_id: relaxed_albert
title:
custom: prom-qb-alltime

View File

@ -2,10 +2,8 @@
## Import
To import the dashboard into your grafana, download the [dashboard.json](https://raw.githubusercontent.com/nohamr/prometheus-qbittorrent-exporter/master/grafana/dashboard.json) file and import it into your server. Select your prometheus instance and that should be all.
To import the dashboard into your grafana, download the [dashboard.json](https://raw.githubusercontent.com/esanchezm/prometheus-qbittorrent-exporter/master/grafana/dashboard.json) file and import it into your server. Select your prometheus instance and that should be all.
## Screenshot
![](./screenshot1.png)
![](./screenshot2.png)
![](./screenshot3.png)
![](./screenshot.png)

File diff suppressed because it is too large Load Diff

BIN
grafana/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

284
pdm.lock generated
View File

@ -1,284 +0,0 @@
# 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"},
]

View File

@ -1,36 +0,0 @@
[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,379 +1,214 @@
import faulthandler
import logging
import os
# from dotenv import load_dotenv
# load_dotenv()
import signal
import sys
import time
from dataclasses import dataclass, field
from enum import Enum, 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
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
# Enable dumps on stderr in case of segfault
faulthandler.enable()
logger = logging.getLogger()
class MetricType(Enum):
"""
Represents possible metric types (used in this project).
"""
GAUGE = auto()
COUNTER = auto()
logger = None
@dataclass
class Metric:
"""
Contains data and metadata about a single counter or gauge.
"""
class QbittorrentMetricsCollector():
TORRENT_STATUSES = [
"downloading",
"uploading",
"complete",
"checking",
"errored",
"paused",
]
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:
def __init__(self, config):
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) -> Iterable[GaugeMetricFamily | CounterMetricFamily]:
"""
Yields Prometheus gauges and counters from metrics collected from qbittorrent.
"""
metrics: list[Metric] = self.get_qbittorrent_metrics()
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()
for metric in metrics:
if metric.metric_type == MetricType.COUNTER:
prom_metric = CounterMetricFamily(
metric.name, metric.help_text, labels=list(metric.labels.keys())
)
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())
else:
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())
)
prom_metric = GaugeMetricFamily(name, help_text, labels=labels.keys())
prom_metric.add_metric(value=value, labels=labels.values())
yield prom_metric
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())
def get_qbittorrent_metrics(self):
metrics = []
metrics.extend(self.get_qbittorrent_status_metrics())
metrics.extend(self.get_qbittorrent_torrent_tags_metrics())
return metrics
def _get_qbittorrent_status_metrics(self) -> list[Metric]:
"""
Returns metrics about the state of the qbittorrent server.
"""
maindata: dict[str, Any] = {}
version: str = ""
def get_qbittorrent_status_metrics(self):
response = {}
version = ""
# Fetch data from API
try:
maindata = self.client.sync_maindata()
response = self.client.transfer.info
version = self.client.app.version
except Exception as e:
logger.error(f"Couldn't get server info: {e}")
server_state = maindata.get('server_state', {})
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")
return [
Metric(
name=f"{self.config['metrics_prefix']}_up",
value=bool(server_state),
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=server_state.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=server_state.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=server_state.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=server_state.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=server_state.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,
),
Metric(
name=f"{self.config['metrics_prefix']}_alltime_dl",
value=server_state.get("alltime_dl", 0),
labels={}, # no labels in the example
help_text="Total data downloaded, in bytes.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_alltime_ul",
value=server_state.get("alltime_ul", 0),
labels={}, # no labels in the example
help_text="Total data uploaded, in bytes.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_total_peer_connections",
value=server_state.get("total_peer_connections", 0),
labels={}, # no labels in the example
help_text="total_peer_connections.",
metric_type=MetricType.COUNTER,
),
#### Disk metrics
Metric(
name=f"{self.config['metrics_prefix']}_write_cache_overload",
value=server_state.get("write_cache_overload", 0),
labels={}, # no labels in the example
help_text="write_cache_overload.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_read_cache_overload",
value=server_state.get("read_cache_overload", 0),
labels={}, # no labels in the example
help_text="read_cache_overload.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_read_cache_hits",
value=server_state.get("read_cache_hits", 0),
labels={}, # no labels in the example
help_text="read_cache_hits.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_average_time_queue",
value=server_state.get("average_time_queue", 0),
labels={}, # no labels in the example
help_text="average_time_queue.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_free_space_on_disk",
value=server_state.get("free_space_on_disk", 0),
labels={}, # no labels in the example
help_text="free_space_on_disk.",
metric_type=MetricType.COUNTER,
),
Metric(
name=f"{self.config['metrics_prefix']}_queued_io_jobs",
value=server_state.get("queued_io_jobs", 0),
labels={}, # no labels in the example
help_text="queued_io_jobs.",
metric_type=MetricType.COUNTER,
),
{
"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"
},
]
def _fetch_categories(self) -> dict:
"""Fetches all categories in use from qbittorrent."""
def get_qbittorrent_torrent_tags_metrics(self):
try:
categories = dict(self.client.torrent_categories.categories)
for key, value in categories.items():
categories[key] = dict(value) # type: ignore
return categories
categories = self.client.torrent_categories.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 []
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": ""}
if not self.torrents:
return []
metrics = []
categories.Uncategorized = AttrDict({'name': 'Uncategorized', 'savePath': ''})
for category in categories:
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)
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}"
})
return metrics
class ShutdownSignalHandler:
class SignalHandler():
def __init__(self):
self.shutdown_count: int = 0
self.shutdown = False
# 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_count > 0
return self.shutdown
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_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"
),
}
self.shutdown = True
def main():
# Init logger so it can be used
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
"%(asctime) %(levelname) %(message)", datefmt="%Y-%m-%d %H:%M:%S"
)
logHandler.setFormatter(formatter)
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"])
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 = ShutdownSignalHandler()
signal_handler = SignalHandler()
# Init logger
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
"%(asctime) %(levelname) %(message)",
datefmt="%Y-%m-%d %H:%M:%S"
)
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(config["log_level"])
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 port specified, please set QBITTORRENT_PORT environment variable"
)
logger.error("No post specified, please set QBITTORRENT_PORT environment variable")
sys.exit(1)
# Register our custom collector
logger.info("Exporter is starting up")
REGISTRY.register(QbittorrentMetricsCollector(config)) # type: ignore
REGISTRY.register(QbittorrentMetricsCollector(config))
# 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()

2
setup.cfg Normal file
View File

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

26
setup.py Normal file
View File

@ -0,0 +1,26 @@
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.2',
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=['attrdict==2.0.1', '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',
]
}
)

View File

View File

@ -1,290 +0,0 @@
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,
),
Metric(
name="qbittorrent_alltime_dl",
value=0,
labels={}, # no labels in the example
help_text="Total data downloaded, in bytes.",
metric_type=MetricType.COUNTER,
),
Metric(
name="qbittorrent_alltime_ul",
value=0,
labels={}, # no labels in the example
help_text="Total data uploaded, in bytes.",
metric_type=MetricType.COUNTER,
),
]
metrics = self.collector._get_qbittorrent_status_metrics()
self.assertEqual(metrics, expected_metrics)

View File

@ -1,13 +0,0 @@
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)