44 Commits
Author SHA1 Message Date
jblu ebec893a85 Merge pull request 'Improve logging: named logger, rotation, richer format' (#62) from logging-improvement into main
test / komodo-deploy-script (push) Successful in 20s
ci / docker (push) Successful in 42s
Reviewed-on: #62
2026-10-27 00:31:29 -05:00
jblu 147758a1e8 Merge pull request 'ci: auto-deploy qbit-maid to Komodo after image push' (#63) from dev-komodo-deploy into main
ci / docker (push) Failing after 21s
test / komodo-deploy-script (push) Successful in 22s
Reviewed-on: #63
2026-10-27 00:31:12 -05:00
Jonathan Branan b2b8f6c10d fix: deterministic exit code for missing Komodo env vars
test / komodo-deploy-script (push) Successful in 16s
ci / docker (push) Successful in 48s
test / komodo-deploy-script (pull_request) Successful in 16s
':?' exits 1 under bash but 2 under dash (the Ubuntu runner's sh),
so the two missing-variable tests failed in CI while passing locally.
Replace with explicit checks so every failure path exits 1.
2026-10-27 00:16:51 -05:00
Jonathan Branan 003217a2b8 test: cover komodo-deploy.sh against a mock Komodo API
ci / docker (push) Successful in 21s
test / komodo-deploy-script (push) Failing after 21s
15 unittest cases exercising the real script end to end: target
selection, auth headers, request shape, success/failure, HTTP 401/400,
unreachable host, polling to completion, and timeout.

Runs in CI via .gitea/workflows/test.yml on push to main/dev* and on
pull requests. No network or live Komodo instance required.
2026-10-27 00:11:53 -05:00
Jonathan Branan 45908b8f6e ci: fix Komodo deploy step permission denied
ci / docker (push) Successful in 5m11s
core.fileMode is false in this repo, so the exec bit on
komodo-deploy.sh was not recorded and the runner hit
'Permission denied'. Mark the file executable in the index and
invoke it via 'sh' so the step no longer depends on the mode bit.
2026-10-26 23:50:28 -05:00
Jonathan Branan ef002b2fce ci: auto-deploy to Komodo stack after image push
ci / docker (push) Failing after 44s
Adds a shared .gitea/scripts/komodo-deploy.sh that calls the Komodo
/execute/DeployStack API after the image is pushed, then polls
/read/GetUpdate until the deploy completes.

A push webhook cannot do this correctly: it fires before the image
exists in the registry, so Komodo would redeploy the previous :latest.

- docker.yml deploys stack 'qbit-maid' (required)
- docker-dev.yml deploys vars.KOMODO_STACK_DEV, skipped when unset
2026-10-26 23:43:42 -05:00
Jonathan Branan f1489ba8a8 docs: plan for automatic Komodo deploys from Gitea Actions 2026-10-26 23:31:04 -05:00
Jonathan Branan ab30d69c7c Improve logging: named logger, rotation, richer format, remove use_log guards
- Add setup_logging() returning a named 'qbit-maid' logger with
  RotatingFileHandler (configurable size/backups) and a console handler
- Add log_torrent() helper for structured torrent context
- Replace basicConfig-based tor_log() with setup_logging()
- Remove redundant 'if self.use_log:' guards across qbit-maid.py,
  qlist.py, qprocess.py, qlogging.py (level filtering handles it)
- Document new logging options in config.toml.example and README
2026-10-26 22:10:35 -05:00
jblu 36d9692086 Merge pull request 'fixed crontab writing jobs multiple times' (#61) from dev-update-crontab into main
ci / docker (push) Successful in 38s
Reviewed-on: #61
2026-04-10 16:40:28 -05:00
jblu 4416ebd924 fixed crontab writing jobs multiple times
ci / docker (push) Successful in 41s
2026-04-10 16:19:36 -05:00
jblu acaf91a301 update readme
ci / docker (push) Successful in 39s
2026-04-09 12:32:59 -05:00
jblu 2213a0655d Merge pull request 'dev-migrate-to-crond' (#60) from dev-migrate-to-crond into main
ci / docker (push) Successful in 40s
Reviewed-on: #60
2026-04-08 20:52:28 -05:00
jblu 6248b7f721 update readme
ci / docker (push) Successful in 38s
2026-04-08 19:12:43 -05:00
jblu e1668dd42d migrated to crond 2026-04-08 19:10:04 -05:00
jblu d15cc8d95c updated healthchecks to support starting and stopping a job
continuous-integration/drone/push Build is passing
2024-06-03 20:32:57 -05:00
jblu 708d56fc98 Updated documentation
continuous-integration/drone/push Build is passing
2023-10-05 08:24:28 -05:00
jblu 6110dade2d Updated help #56
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-10-05 07:37:21 -05:00
jblu da7951384f Merge pull request 'dev-fix-calculations' (#59) from dev-fix-calculations into main
continuous-integration/drone/push Build is passing
Reviewed-on: #59
2023-10-05 07:29:00 -05:00
jblu c3865d5fbb Updated test cases
continuous-integration/drone/push Build is passing
2023-10-04 15:03:11 -05:00
jblu 24f6e7b563 updated unit tests and fixed a bug
continuous-integration/drone/push Build is passing
2023-09-20 00:18:26 -05:00
jblu 588382ae14 fixed unit test
continuous-integration/drone/push Build is passing
2023-09-12 14:03:15 -05:00
jblu 01a4c88cc4 Fixed #58
continuous-integration/drone/push Build is failing
2023-09-12 13:04:38 -05:00
jblu 8e70374ae7 test
continuous-integration/drone/push Build is passing
2023-08-09 10:47:27 -05:00
jblu ce5ae0261c changed drone 2023-08-06 23:38:39 -05:00
jblu 4cfb4a88c1 added badge
continuous-integration/drone/push Build is passing
2023-07-23 00:39:39 -05:00
jblu 43c9324a3f updated documentation #56
continuous-integration/drone/push Build is passing
2023-07-23 00:32:15 -05:00
jblu d2ebddf473 Merge pull request 'updated testing' (#54) from dev-update-testing into main
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/54
2023-06-27 12:46:20 -05:00
jblu a4fc54afe5 updated testing
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2023-06-27 12:43:10 -05:00
Jonathan Branan 728e429979 fixed build process
continuous-integration/drone/push Build is passing
2023-06-23 04:24:14 -05:00
Jonathan Branan b8a31c43de updated drone
continuous-integration/drone/push Build is passing
2023-06-23 04:20:12 -05:00
jblu 167575fe5d Merge pull request 'migrated to supercronic' (#53) from dev-migrate-supercronic into main
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/53
2023-06-23 04:05:51 -05:00
Jonathan Branan ca1430b302 migrated to supercronic
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-23 04:02:05 -05:00
jblu 660a18a70f disable warnings
continuous-integration/drone/push Build is passing
2023-06-11 14:41:50 -05:00
jblu 7a6787888c tag only latest
continuous-integration/drone/push Build is passing
2023-06-06 15:20:20 -05:00
jblu 65598d97ac enabled drone
continuous-integration/drone/push Build is passing
2023-06-06 15:17:31 -05:00
jblu e2cdca60c3 Merge pull request 'intergrate-drone' (#51) from intergrate-drone into main
continuous-integration/drone/push Build is passing
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/51
2023-06-06 15:06:12 -05:00
jblu 978e9326cc updated to execute on main branch
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-06 14:47:03 -05:00
jblu beee95caa4 updated to use secret
continuous-integration/drone/push Build is passing
2023-06-06 14:44:56 -05:00
jblu 6ce2add157 added drone support
continuous-integration/drone/push Build is passing
2023-06-06 14:33:07 -05:00
jblu dc533a33b1 updated docker ignore #50 2023-06-06 14:18:11 -05:00
jblu 5e1c963b72 Merge pull request 'added health check support' (#47) from add-healthcheck-support into main
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/47
2023-06-05 23:55:01 -05:00
jblu cf64321e49 added health check support 2023-06-05 23:51:49 -05:00
jblu a0ce03335d Merge pull request 'fixed config init' (#46) from fix-config-init into main
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/46
2023-06-05 22:43:06 -05:00
jblu b6346ac335 fixed config init 2023-06-05 22:42:00 -05:00
19 changed files with 1059 additions and 214 deletions

No files matched your search

+6 -1
View File
@@ -3,7 +3,6 @@ LICENSE
*.log *.log
README.md README.md
requirements.txt requirements.txt
test_*
Dockerfile Dockerfile
*docker-test* *docker-test*
*.log *.log
@@ -11,6 +10,12 @@ Dockerfile
*.csv *.csv
*.toml *.toml
*.git* *.git*
.dockerignore
.DS_Store
.vscode/*
thunder-tests/*
.drone.yml
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+173
View File
@@ -0,0 +1,173 @@
#!/bin/sh
#
# Trigger a Komodo deploy and optionally wait for it to finish.
#
# Required env:
# KOMODO_URL Base URL of Komodo Core, e.g. https://k.jonb.io
# KOMODO_API_KEY Komodo API key
# KOMODO_API_SECRET Komodo API secret
# KOMODO_TARGET Stack name/id (or Procedure name/id)
#
# Optional env:
# KOMODO_TARGET_TYPE "stack" (default) or "procedure"
# KOMODO_REQUIRED "true" to fail when KOMODO_TARGET is empty
# KOMODO_WAIT "true" (default) to poll until the update completes
# KOMODO_WAIT_TIMEOUT Seconds to wait for completion (default 600)
# KOMODO_WAIT_INTERVAL Seconds between polls (default 10)
#
# Exits non-zero if the request is rejected or the deploy fails.
set -eu
KOMODO_TARGET_TYPE="${KOMODO_TARGET_TYPE:-stack}"
KOMODO_REQUIRED="${KOMODO_REQUIRED:-false}"
KOMODO_WAIT="${KOMODO_WAIT:-true}"
KOMODO_WAIT_TIMEOUT="${KOMODO_WAIT_TIMEOUT:-600}"
KOMODO_WAIT_INTERVAL="${KOMODO_WAIT_INTERVAL:-10}"
if [ -z "${KOMODO_TARGET:-}" ]; then
if [ "$KOMODO_REQUIRED" = "true" ]; then
echo "::error::KOMODO_TARGET is empty but KOMODO_REQUIRED=true" >&2
exit 1
fi
echo "KOMODO_TARGET is not set - skipping Komodo deploy."
exit 0
fi
# Explicit checks rather than ':?', whose exit status differs between
# shells (bash exits 1, dash exits 2). Keep every failure path at exit 1.
if [ -z "${KOMODO_URL:-}" ]; then
echo "::error::KOMODO_URL is required" >&2
exit 1
fi
if [ -z "${KOMODO_API_KEY:-}" ]; then
echo "::error::KOMODO_API_KEY is required" >&2
exit 1
fi
if [ -z "${KOMODO_API_SECRET:-}" ]; then
echo "::error::KOMODO_API_SECRET is required" >&2
exit 1
fi
# Normalise: strip any trailing slash so path joins are predictable.
KOMODO_URL="${KOMODO_URL%/}"
case "$KOMODO_TARGET_TYPE" in
stack)
EXECUTE="DeployStack"
PARAMS="{\"stack\": \"${KOMODO_TARGET}\"}"
;;
procedure)
EXECUTE="RunProcedure"
PARAMS="{\"procedure\": \"${KOMODO_TARGET}\"}"
;;
*)
echo "::error::KOMODO_TARGET_TYPE must be 'stack' or 'procedure', got '${KOMODO_TARGET_TYPE}'" >&2
exit 1
;;
esac
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# api <path> <json-body> -> writes body to $tmp, echoes HTTP status code
api() {
curl -sS -o "$tmp" -w '%{http_code}' \
-X POST "${KOMODO_URL}$1" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${KOMODO_API_KEY}" \
-H "x-api-secret: ${KOMODO_API_SECRET}" \
-d "$2"
}
echo "Requesting ${EXECUTE} for '${KOMODO_TARGET}' on ${KOMODO_URL}"
if ! code=$(api "/execute/${EXECUTE}" "$PARAMS"); then
echo "::error::Could not reach Komodo at ${KOMODO_URL}" >&2
exit 1
fi
body=$(cat "$tmp")
if [ "$code" != "200" ]; then
echo "::error::Komodo rejected ${EXECUTE} for '${KOMODO_TARGET}' (HTTP ${code})" >&2
echo "$body" >&2
exit 1
fi
echo "Komodo accepted the request (HTTP 200)."
# The execute endpoint returns immediately with an Update; the deploy itself
# runs asynchronously. Poll /read/GetUpdate until it reaches a terminal state.
if [ "$KOMODO_WAIT" != "true" ]; then
echo "KOMODO_WAIT is not 'true' - not waiting for completion."
exit 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not available - cannot poll for completion. Deploy was queued."
exit 0
fi
update_id=$(printf '%s' "$body" | python3 -c '
import json, sys
d = json.load(sys.stdin)
print(d.get("_id", {}).get("$oid", ""))
' 2>/dev/null || true)
if [ -z "$update_id" ]; then
echo "Could not read update id from response - not polling. Deploy was queued."
exit 0
fi
echo "Waiting for update ${update_id} (timeout ${KOMODO_WAIT_TIMEOUT}s)..."
elapsed=0
while [ "$elapsed" -lt "$KOMODO_WAIT_TIMEOUT" ]; do
sleep "$KOMODO_WAIT_INTERVAL"
elapsed=$((elapsed + KOMODO_WAIT_INTERVAL))
if ! code=$(api "/read/GetUpdate" "{\"id\": \"${update_id}\"}"); then
echo "::warning::Could not reach Komodo while polling; retrying." >&2
continue
fi
if [ "$code" != "200" ]; then
echo "::warning::GetUpdate returned HTTP ${code}; retrying." >&2
continue
fi
status=$(python3 -c '
import json, sys
print(json.load(sys.stdin).get("status", ""))
' < "$tmp" 2>/dev/null || true)
case "$status" in
Complete)
if python3 -c '
import json, sys
sys.exit(0 if json.load(sys.stdin).get("success") else 1)
' < "$tmp" 2>/dev/null; then
echo "Deploy of '${KOMODO_TARGET}' completed successfully."
exit 0
fi
echo "::error::Deploy of '${KOMODO_TARGET}' failed. Komodo update log:" >&2
python3 -c '
import json, sys
d = json.load(sys.stdin)
for log in d.get("logs", []):
print(f" [{log.get(\"stage\", \"\")}] {log.get(\"msg\", \"\")}")
' < "$tmp" >&2 2>/dev/null || cat "$tmp" >&2
exit 1
;;
Queued|InProgress)
echo " ${elapsed}s: ${status}"
;;
*)
echo " ${elapsed}s: unexpected status '${status}'"
;;
esac
done
echo "::error::Timed out after ${KOMODO_WAIT_TIMEOUT}s waiting for update ${update_id}" >&2
exit 1
+43
View File
@@ -0,0 +1,43 @@
name: ci
on:
push:
branches:
- dev*
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: git.jonb.io
username: '${{ gitea.actor }}'
password: '${{ secrets.JONBIO_CI }}'
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: git.jonb.io/jblu/qbit-maid:dev
-
name: Checkout
uses: actions/checkout@v4
-
name: Deploy to Komodo
env:
KOMODO_URL: '${{ secrets.KOMODO_URL }}'
KOMODO_API_KEY: '${{ secrets.KOMODO_API_KEY }}'
KOMODO_API_SECRET: '${{ secrets.KOMODO_API_SECRET }}'
# Set the KOMODO_STACK_DEV repository variable to your dev stack name.
# When it is empty the deploy step is skipped, so dev builds still pass.
KOMODO_TARGET: '${{ vars.KOMODO_STACK_DEV }}'
run: sh ./.gitea/scripts/komodo-deploy.sh
+42
View File
@@ -0,0 +1,42 @@
name: ci
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: git.jonb.io
username: '${{ gitea.actor }}'
password: '${{ secrets.JONBIO_CI }}'
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: git.jonb.io/jblu/qbit-maid:latest
-
name: Checkout
uses: actions/checkout@v4
-
name: Deploy to Komodo
env:
KOMODO_URL: '${{ secrets.KOMODO_URL }}'
KOMODO_API_KEY: '${{ secrets.KOMODO_API_KEY }}'
KOMODO_API_SECRET: '${{ secrets.KOMODO_API_SECRET }}'
KOMODO_TARGET: 'qbit-maid'
KOMODO_REQUIRED: 'true'
run: sh ./.gitea/scripts/komodo-deploy.sh
+17
View File
@@ -0,0 +1,17 @@
name: test
on:
push:
branches:
- main
- dev*
pull_request:
jobs:
komodo-deploy-script:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run komodo-deploy.sh tests
run: python3 -m unittest -v test_komodo_deploy
+3 -5
View File
@@ -1,8 +1,6 @@
FROM python:alpine3.18 FROM python:alpine3.23
WORKDIR / WORKDIR /
COPY . opt COPY *.py entrypoint.sh /opt/
RUN pip install requests RUN pip install requests qbittorrent-api
RUN pip install qbittorrent-api
RUN crontab /opt/crontab
RUN chmod +x /opt/entrypoint.sh RUN chmod +x /opt/entrypoint.sh
CMD ["/opt/entrypoint.sh"] CMD ["/opt/entrypoint.sh"]
+104 -84
View File
@@ -1,6 +1,7 @@
# qbit-maid # qbit-maid
> Warning: This application removes torrents that are over the minimum age and that are not part of the ignored categories, domains or tags. Please use the delete_torrents feature set to false when first testing its functionality.
## Warning: This application removes torrents that are over the minimum age and that are not part of the ignored categories, domains or tags. Please use the delete_torrents feature set to false when first testing its functionality. ![Build Status](https://git.jonb.io/jblu/qbit-maid/actions/workflows/docker.yml/badge.svg)
The objective is to remove torrents based on the following criteria: The objective is to remove torrents based on the following criteria:
- tracker domain name - tracker domain name
@@ -8,94 +9,113 @@ The objective is to remove torrents based on the following criteria:
- ratio - ratio
- state - state
```mermaid ## Install
graph TD; ### Docker(Recommended)
qbit-maid.py-->qlogging.py;
qbit-maid.py-->qlist.py; [package](https://git.jonb.io/jblu/-/packages/container/qbit-maid/latest)
qbit-maid.py-->qprocess.py;
qlogging.py-->qbit-maid.py; docker pull git.jonb.io/jblu/qbit-maid:latest
qlist.py-->qbit-maid.py;
qprocess.py-->qbit-maid.py; #### Docker Run Command:
> Please note it is best practice to escape spaces in variables. That is why there is backslashes in the cron schedule.
docker run --name qbit-maid -v /opt/qbit-maid:/config/ -e CRON=0\ 1\ *\ *\ * -e toml_path=/config/config.toml git.jonb.io/jblu/qbit-maid
#### Docker Compose
```
version: '3.3'
services:
qbit-maid:
image: git.jonb.io/jblu/qbit-maid
container_name: qbit-maid
volumes:
- /opt/qbit-maid:/config
environment:
- CRON="0 1 * * *"
- toml_path=/config/config.toml
```
### Via Git
git clone https://git.jonb.io/jblu/qbit-maid.git
Qbit-maid will look for an environment variable *toml_path* for its configuration.If it doesn't find it, it will look for a config.toml file in it's own directory.
##### config.toml
```
[qbittorrent]
host = "192.168.x.x"
port = 8080
username = "user"
password = "pass"
[logging]
use_log = true
log_level = "INFO"
log_path = "./qc.log"
# Optional: log rotation (defaults shown)
log_max_bytes = 5000000
log_backup_count = 3
[app_tags]
protected_tag = "ipt"
non_protected_tag = "public"
[torrent]
age = 2419200
minimum_age = 432000
delete_torrents = false
[pushover]
use_pushover = false
po_key = "<key>>"
po_token = "<token>>"
[apprise]
use_apprise = false
host = "192.168.x.x"
port = 8088
aurls = 'mailto://user:pass@gmail.com'
[dragnet]
enable_dragnet = false
dragnet_outfile = "./orphaned.csv"
[telemetry]
enable_telemetry = false
telemetry_outfile = "./telemetry.csv"
[ignored_categories]
tech = "tech"
books = "books"
general = "general"
[ignored_domains]
iptorrents-empirehost = "ssl.empirehost.me"
iptorrents-stackoverflow = "localhost.stackoverflow.tech"
iptorrents-bgp = "routing.bgp.technology"
[ignored_tags]
save = "save"
[healthcheck]
use_healthcheck = false
healthcheck_url = "https://example.com/ping/<uuid>>"
``` ```
| File | Purpose | ## Logging
| --- | --- |
| qbit-maid.py | Client to the qbit api and calls functions from the other files |
| qlist.py | Builds out torrent lists |
| qlogging.py | Logging and push notification communication |
| qprocess.py | Submits qualifying torrents for deletion |
| test_qbitmaid.py | Unit tests |
| ignored_categories.json | whitelist for categorys to ignore |
| ignored_tags.json | whitelist for torrent tags to ignore |
| ignored_trackers.json | whitelist of fqdn names to ignore |
You will need a config.json in the root directory. qbit-maid uses a named logger (`qbit-maid`) that writes to both the configured
log file and the console (useful for Docker/cron output).
| Key | Value | - `use_log` — enable or disable logging entirely.
| --- | --- | - `log_level` — one of `DEBUG`, `INFO`, `WARN`, `ERROR`, `CRITICAL`.
| host | string, ip or hostname of qbittorrent server | - `log_path` — path to the log file.
| port | number, port of admin gui(used for api aswell) | - `log_max_bytes` — rotate the log file once it reaches this size (default 5 MB).
| username | admin account for qbittorrent | - `log_backup_count` — number of rotated backups to keep (default 3).
| password | password for admin account |
| log_level | is what log messages are written to the log file. INFO or DEBUG are valid entries(case sensitive) |
| protected_tag | used to mark torrents to handle with care |
| non_protected_tag | we don't care about these torrents |
| log_path | will write a log in root directory if left as is other wise specify other path using forward slashes |
| age | number, seconds for how long we keep torrents from IPTORRENTS |
| minimum_age | age in seconds torrents should reached before they are removed |
| use_pushover | true or false to enable or disable pushover notification summary |
| use_log | true or false to enable or disable writing to alog file |
| po_key | pushover key |
| po_token | pushover api token |
| delete_torrents | true or false to enable or disable deletion. Useful for dry-runs |
| enable_dragnet | true or false to enable dragnet functionality. Useful for debugging |
It should look something like this: Log lines include the timestamp, level, logger name, function, and line number,
Config.json for example:
```
{
"host": "192.168.1.1",
"port": 8080,
"username": "admin",
"password": "admin",
"loglevel": "INFO",
"logpath": "./qc.log",
"protected_tag": "ipt",
"non_protected_tag": "public",
"age": 2419200,
"minimum_age": 432000,
"use_pushover": false,
"use_log": true,
"po_key": "",
"po_token": "",
"delete_torrents": false
"enable_dragnet": false,
"dragnet_outfile": "./orphaned.csv"
}
```
You will need a ignored_categories.json in the root directory. This will ignore any of the categories found in the values of the entries.
``` ```
{ 10/26/2026 10:10:00 PM [INFO] qbit-maid:tor_processor:24 - Submitted ["My Torrent..."] for deletion.
"example": "general",
"example2": "sonarr"
}
```
You will need a ignored_domains.json in the root directory. This will ignore any torrents from these trackers.
```
{
"iptorrents-empirehost": "ssl.empirehost.me",
"iptorrents-stackoverflow": "localhost.stackoverflow.tech",
"iptorrents-bgp": "routing.bgp.technology"
}
```
You will need a ignored_tags.json in the root directory. This will ignore any torrents with these tags.
```
{
"first":"first",
"second":"second",
"third":"third"
}
``` ```
+20 -14
View File
@@ -1,35 +1,39 @@
[qbittorrent] [qbittorrent]
host = "192.168.4.11" host = "192.168.x.x"
port = 8085 port = 8080
username = "jman" username = "user"
password = "nO^touchy@" password = "pass"
[logging] [logging]
use_log = true use_log = true
log_level = "DEBUG" log_level = "INFO"
log_path = "./qc.log" log_path = "./qc.log"
# Optional: log rotation (defaults shown)
log_max_bytes = 5000000
log_backup_count = 3
[app_tags] [app_tags]
protected_tag = "ipt" protected_tag = "ipt"
non_protected_tag = "public" non_protected_tag = "public"
[torrent] [torrent]
age = 2419200 max_age = 2419200
minimum_age = 432000 min_age = 432000
delete_torrents = true delete_torrents = false
[pushover] [pushover]
use_pushover = false use_pushover = false
po_key = "" po_key = "<key>>"
po_token = "" po_token = "<token>>"
[apprise] [apprise]
use_apprise = false
host = "192.168.x.x" host = "192.168.x.x"
port = 8088 port = 8088
aurls = 'mailto://user:pass@gmail.com' aurls = 'mailto://user:pass@gmail.com'
[dragnet] [dragnet]
enable_dragnet = true enable_dragnet = false
dragnet_outfile = "./orphaned.csv" dragnet_outfile = "./orphaned.csv"
[ignored_categories] [ignored_categories]
@@ -43,6 +47,8 @@ iptorrents-stackoverflow = "localhost.stackoverflow.tech"
iptorrents-bgp = "routing.bgp.technology" iptorrents-bgp = "routing.bgp.technology"
[ignored_tags] [ignored_tags]
first = "first" save = "save"
second = "second"
third = "third" [healthcheck]
use_healthcheck = true
healthcheck_url = "https://example.com/ping/<uuid>>"
-1
View File
@@ -1 +0,0 @@
0 1 * * * . /etc/environment; python /opt/qbit-maid.py >> /logfile
+143
View File
@@ -0,0 +1,143 @@
# Auto-deploy qbit-maid to Komodo
Status: **implemented** on branch `dev-komodo-deploy`.
Komodo instance: `https://k.jonb.io` · Stack: `qbit-maid`
## What this does
Every successful image build now triggers a Komodo stack deploy:
| Workflow | Trigger | Image tag | Komodo target |
|---|---|---|---|
| `.gitea/workflows/docker.yml` | push to `main` | `:latest` | stack `qbit-maid` |
| `.gitea/workflows/docker-dev.yml` | push to `dev*` | `:dev` | `vars.KOMODO_STACK_DEV` (skipped if unset) |
The deploy step runs **after** `Build and push`, so the new image is already in
the registry when Komodo pulls it. If the deploy fails, the workflow goes red.
## Why not just use the existing webhook?
You already have webhooks configured, and this is the important part: **a push
webhook on the stack does not reliably deploy new container builds.**
The webhook fires on `git push`. That happens *before* Gitea Actions has built
and pushed the image — the build takes minutes. So Komodo receives the deploy
request while the registry still holds the *previous* `:latest`. It pulls that,
deploys it, and reports success. The new code never lands until something else
triggers a second deploy.
The ordering is the whole problem, and a webhook cannot fix it because the
webhook is tied to the push event, not the build event.
So the workflow calls the Komodo API directly instead:
```
POST https://k.jonb.io/execute/DeployStack
x-api-key: <key>
x-api-secret: <secret>
{"stack": "qbit-maid"}
```
This is the same call the Komodo UI makes when you click **Deploy**, just
triggered from CI at the right moment.
### What to do with the existing webhook
If you have a stack webhook pointing at
`/listener/github/stack/qbit-maid/deploy`, it is now redundant and will cause a
second, useless deploy on every push (deploying the stale image). **Remove it**
in Gitea → repo Settings → Webhooks.
Keep webhooks only if you later point them at a *Procedure* rather than the
stack — see "Using a Procedure" below.
## One Komodo setting to verify
`DeployStack` runs `docker compose pull` before `up` **only when the stack has
`auto_pull` enabled**. It defaults to `true`, so this is probably already fine —
but if it has been turned off, deploys will silently reuse the cached image and
you will see no change.
Check in the Komodo UI: stack `qbit-maid` → Config → **Auto Pull** should be on.
## Setup (one-time)
### 1. Komodo API key
Komodo → **Settings → API Keys → New**. The key's user needs **Execute**
permission on the `qbit-maid` stack. Copy the key and secret — the secret is
shown once.
### 2. Gitea secrets
Repo → **Settings → Actions → Secrets**:
| Name | Value |
|---|---|
| `KOMODO_URL` | `https://k.jonb.io` |
| `KOMODO_API_KEY` | from step 1 |
| `KOMODO_API_SECRET` | from step 1 |
### 3. Dev stack (optional)
Repo → **Settings → Actions → Variables**:
| Name | Value |
|---|---|
| `KOMODO_STACK_DEV` | your dev stack name, e.g. `qbit-maid-dev` |
If this variable is unset, the dev workflow logs `KOMODO_TARGET is not set -
skipping Komodo deploy` and still passes. Dev builds never fail because of a
missing dev stack.
## The deploy script
`.gitea/scripts/komodo-deploy.sh` is shared by both workflows. It:
1. Skips cleanly if `KOMODO_TARGET` is empty (unless `KOMODO_REQUIRED=true`).
2. POSTs to `/execute/DeployStack` (or `/execute/RunProcedure`).
3. Fails on any non-200, printing Komodo's error body.
4. Polls `/read/GetUpdate` until the update reaches `Complete`, then checks
`success` and prints the Komodo log on failure.
The execute endpoint returns immediately with an `Update` object — a `200` means
"queued", not "deployed". That is why step 4 exists.
Tunable via env: `KOMODO_TARGET_TYPE` (`stack`/`procedure`), `KOMODO_WAIT`
(default `true`), `KOMODO_WAIT_TIMEOUT` (default 600s), `KOMODO_WAIT_INTERVAL`
(default 10s).
### Using a Procedure instead of a direct stack deploy
If you later wrap the deploy in a Komodo Procedure (deploy + healthcheck +
notification, say), point the workflow at it instead of editing the script:
```yaml
env:
KOMODO_TARGET_TYPE: 'procedure'
KOMODO_TARGET: 'qbit-maid deploy'
```
`RunProcedure` takes `{"procedure": "<name>"}` and returns the same `Update`
shape, so the polling logic is unchanged.
## Testing
Verified locally against a mock of the Komodo API — 12 checks covering the
success path, deploy failure, bad credentials (401), unknown stack (400),
empty target skip, required-but-empty target, unreachable host, and the
procedure target type. All pass.
Live verification still needed:
1. Push `dev-komodo-deploy` — it matches `dev*`, so `docker-dev.yml` runs. With
`KOMODO_STACK_DEV` unset it will skip the deploy; set the variable first if
you want to exercise it.
2. Merge to `main` and confirm the Komodo UI shows a new `DeployStack` update
for `qbit-maid`, operator = the API key's user.
3. Confirm the running container picked up the new image digest.
## Rollback
Delete the `Deploy to Komodo` step from the workflow(s). Nothing on the Komodo
side is modified by CI beyond triggering a deploy.
+5 -2
View File
@@ -1,5 +1,8 @@
#!/bin/sh #!/bin/sh
printenv | grep -v "no_proxy" >> /etc/environment CRON_CONFIG_FILE="/etc/crontabs/root"
crond -f grep -qF 'python /opt/qbit-maid.py' $CRON_CONFIG_FILE || echo "${CRON} python /opt/qbit-maid.py" >> $CRON_CONFIG_FILE
grep -qF '@reboot' $CRON_CONFIG_FILE || echo "@reboot python /opt/qbit-maid.py" >> $CRON_CONFIG_FILE
exec crond -f
+35 -17
View File
@@ -12,16 +12,18 @@ from collections import Counter
import csv import csv
import requests as r import requests as r
import os import os
import sys r.packages.urllib3.disable_warnings()
class Qbt: class Qbt:
def __init__(self): def __init__(self):
"""Main object, should be calling functions from qlist.py, qlogging.py and qprocess.py""" """Main object, should be calling functions from qlist.py, qlogging.py and qprocess.py"""
# Open the config. Needs a json file with the data in config.json.example # Open the config. Needs a json file with the data in config.json.example
self.st = datetime.datetime.now() self.st = datetime.datetime.now()
config_file_path=os.environ["toml_path"] if os.getenv("toml_path"):
with open(config_file_path, 'rb') as c: config_file_path=os.getenv("toml_path")
self.config = load(c) with open(config_file_path, 'rb') as c:
self.config = load(c)
if os.path.exists('./config.toml'): if os.path.exists('./config.toml'):
config_file_path = './config.toml' config_file_path = './config.toml'
with open(config_file_path, 'rb') as c: with open(config_file_path, 'rb') as c:
@@ -36,7 +38,6 @@ class Qbt:
password=self.config["qbittorrent"]["password"], password=self.config["qbittorrent"]["password"],
) )
# Create the logging and pushover objects # Create the logging and pushover objects
self.tl = logging
self.po = pushover self.po = pushover
self.ct = Counter self.ct = Counter
self.cv = csv self.cv = csv
@@ -46,6 +47,12 @@ class Qbt:
self.use_log = self.config["logging"]["use_log"] self.use_log = self.config["logging"]["use_log"]
self.log_path = self.config["logging"]["log_path"] self.log_path = self.config["logging"]["log_path"]
self.log_level = self.config["logging"]["log_level"] self.log_level = self.config["logging"]["log_level"]
self.log_max_bytes = self.config["logging"].get("log_max_bytes", 5_000_000)
self.log_backup_count = self.config["logging"].get("log_backup_count", 3)
self.tl = setup_logging(
self.use_log, self.log_path, self.log_level,
self.log_max_bytes, self.log_backup_count,
)
#app_tags #app_tags
self.tracker_protected_tag = self.config["app_tags"]["protected_tag"] self.tracker_protected_tag = self.config["app_tags"]["protected_tag"]
@@ -53,8 +60,8 @@ class Qbt:
#torrent #torrent
self.delete_torrents = self.config["torrent"]["delete_torrents"] self.delete_torrents = self.config["torrent"]["delete_torrents"]
self.minimum_age = self.config["torrent"]["minimum_age"] self.min_age = self.config["torrent"]["min_age"]
self.age = self.config["torrent"]["age"] self.max_age = self.config["torrent"]["max_age"]
#pushover #pushover
self.use_pushover = self.config["pushover"]["use_pushover"] self.use_pushover = self.config["pushover"]["use_pushover"]
@@ -71,6 +78,10 @@ class Qbt:
self.enable_dragnet = self.config["dragnet"]["enable_dragnet"] self.enable_dragnet = self.config["dragnet"]["enable_dragnet"]
self.dragnet_outfile = self.config["dragnet"]["dragnet_outfile"] self.dragnet_outfile = self.config["dragnet"]["dragnet_outfile"]
#telemetry
self.enable_telemetry = self.config["telemetry"]["enable_telemetry"]
self.telemetry_outfile = self.config["telemetry"]["telemetry_outfile"]
#ignored_categories #ignored_categories
self.cat_whitelist = self.config["ignored_categories"] self.cat_whitelist = self.config["ignored_categories"]
@@ -80,18 +91,25 @@ class Qbt:
#ignored_tags #ignored_tags
self.ignored_tags = self.config["ignored_domains"] self.ignored_tags = self.config["ignored_domains"]
#healthcheck
self.use_healthcheck = self.config["healthcheck"]["use_healthcheck"]
self.healthcheck_url = self.config["healthcheck"]["healthcheck_url"]
# Calling log and notify functions # Calling log and notify functions
tor_log(self)
tor_notify(self) tor_notify(self)
self.t = time self.t = time
#start healthcheck job
if self.use_healthcheck:
send_ping(self, r, self.healthcheck_url.rstrip("/") + "/start" )
# Pulling domain names to treat carefully # Pulling domain names to treat carefully
self.tracker_list = [] self.tracker_list = []
self.up_tor_counter = 0 self.up_tor_counter = 0
self.preme_tor_counter = 0 self.preme_tor_counter = 0
self.ignored_counter = 0 self.ignored_counter = 0
self.torrent_hash_delete_list = [] self.torrent_hash_delete_list = []
if self.use_log: self.tl.debug(self.tracker_whitelist)
self.tl.debug(self.tracker_whitelist)
#logging in #logging in
try: try:
self.tl.info('Connecting to host.') self.tl.info('Connecting to host.')
@@ -103,16 +121,14 @@ class Qbt:
# Pulling all torrent data # Pulling all torrent data
self.torrent_list = self.qbt_client.torrents_info() self.torrent_list = self.qbt_client.torrents_info()
#Main process block #Main process block
if self.use_log: list_qbit_api_info(self)
list_qbit_api_info(self) list_first_tor(self)
list_first_tor(self) debug_torrent_list(self)
build_tor_list(self) build_tor_list(self)
process_counts(self) process_counts(self)
if self.use_log: torrent_count(self)
torrent_count(self)
tor_processor(self) tor_processor(self)
if self.use_log: print_processor(self)
print_processor(self)
if self.delete_torrents: if self.delete_torrents:
tor_delete(self) tor_delete(self)
self.et = datetime.datetime.now() self.et = datetime.datetime.now()
@@ -121,6 +137,8 @@ class Qbt:
tor_notify_summary(self) tor_notify_summary(self)
if self.use_apprise: if self.use_apprise:
tor_notify_apprise(self, r, apprise_notify) tor_notify_apprise(self, r, apprise_notify)
if self.use_healthcheck:
send_ping(self, r, self.healthcheck_url)
# Run # Run
if __name__== "__main__": if __name__== "__main__":
Qbt() Qbt()
+13 -20
View File
@@ -1,57 +1,50 @@
def build_tor_list(self): def build_tor_list(self):
"""Builds multiple lists of torrents to be sorted. Also adds tags to the torents. """Builds multiple lists of torrents to be sorted. Also adds tags to the torents.
There are more effecient ways of doing things but I did this rather quickly. There are more effecient ways of doing things but I did this rather quickly.
V2 will certainly be more performant. The reason two lists were used was so that torrents V2 will certainly be more performant. The reason two lists were used was so that torrents
that are in public trackers woudln't be around as long as torrents from a private tracker. that are in public trackers woudln't be around as long as torrents from a private tracker.
""" """
self.total_torrents = len(self.torrent_list) self.total_torrents = len(self.torrent_list)
while self.torrent_list: while self.torrent_list:
torrent = self.torrent_list.pop() torrent = self.torrent_list.pop()
if self.use_log: self.tl.debug(f'---Analyzing ["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}---')
self.tl.debug(f'---Analyzing ["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}---')
# Need a way to tag when the tracker is blank # Need a way to tag when the tracker is blank
if is_tracker_blank(torrent['tracker']): if is_tracker_blank(torrent['tracker']):
if self.use_log: self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] hash: {torrent["hash"]}')
self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] [{torrent["tracker"]}]hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
elif is_cat_ignored(torrent['category'], self.cat_whitelist.values()): elif is_cat_ignored(torrent['category'], self.cat_whitelist.values()):
if self.use_log: self.tl.info(f'Ignored category: ["{torrent["name"][0:20]}..."] category:[{torrent["category"]}] hash: {torrent["hash"]}')
self.tl.info(f'Ignored category: ["{torrent["name"][0:20]}..."] category:[{torrent["category"]}] hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
elif is_ignored_tag(self.ignored_tags.values(),torrent['tags']): elif is_ignored_tag(self.ignored_tags.values(),torrent['tags']):
if self.use_log: self.tl.info(f'Ignored tag: ["{torrent["name"][0:20]}..."] tags: {torrent["tags"]} hash: {torrent["hash"]}')
self.tl.info(f'Ignored tag: ["{torrent["name"][0:20]}..."] tags: {torrent["tags"]} hash: {torrent["hash"]}')
self.ignored_counter += 1 self.ignored_counter += 1
continue continue
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
if self.use_log: self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
if is_preme(torrent['added_on'], self.minimum_age, self.t.time()): if is_preme(torrent['seeding_time'], self.min_age):
self.preme_tor_counter += 1 self.preme_tor_counter += 1
self.tl.debug(f'Premature torrent: ["{torrent["name"][0:20]}..."] hash: {torrent["hash"]}') self.tl.debug(f'Premature torrent: ["{torrent["name"][0:20]}..."] Seconds Seeded: [{torrent["seeding_time"]}] hash: {torrent["hash"]}')
continue continue
elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
if self.use_log: self.tl.debug(f'Tagging Protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging Protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tracker_list.append(torrent) self.tracker_list.append(torrent)
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()): elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']): if is_tag_blank(torrent['tags']):
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash']) self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
if self.use_log: self.tl.debug(f'Tagging Non-protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tl.debug(f'Tagging Non-protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
self.tracker_list.append(torrent) self.tracker_list.append(torrent)
def is_preme(added, minage, time): def is_preme(seeding_time, minage):
if added + minage >= time: if seeding_time <= minage:
return True return True
def is_cat_ignored(cat, catlist): def is_cat_ignored(cat, catlist):
+97 -14
View File
@@ -1,14 +1,6 @@
def tor_log(self): import logging
"""Setting up the log file, if self.use_log is set to true and self.loglevel is DEBUG OR INFO""" from logging.handlers import RotatingFileHandler
if self.use_log:
if self.log_level.lower() == 'debug':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.DEBUG)
elif self.log_level.lower() == 'info':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.INFO)
elif self.log_level.lower() == 'warn':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.WARN)
elif self.log_level.lower() == 'error':
self.tl.basicConfig(filename=self.log_path, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.ERROR)
def tor_notify(self): def tor_notify(self):
"""Seting up to use pushover, if self.use_pushover is set to true and """Seting up to use pushover, if self.use_pushover is set to true and
@@ -79,9 +71,100 @@ def torrent_count(self):
def get_script_runtime(self): def get_script_runtime(self):
elapsed_time = self.et - self.st elapsed_time = self.et - self.st
if self.use_log: self.tl.info(f'Execution time: [{elapsed_time}]')
self.tl.info(f'Execution time: [{elapsed_time}]')
if self.use_pushover: if self.use_pushover:
self.extm = f"Execution time: [{elapsed_time}]" self.extm = f"Execution time: [{elapsed_time}]"
if self.use_apprise: if self.use_apprise:
self.extm = f"Execution time: [{elapsed_time}]" self.extm = f"Execution time: [{elapsed_time}]"
def send_ping(self, req_obj, healthcheck_url):
try:
req_obj.get(healthcheck_url, timeout=5)
except req_obj.RequestException as e:
self.tl.info(f"Ping failed: {e}")
def debug_torrent_list(self):
self.tl.debug(self.torrent_list)
# ---------------------------------------------------------------------------
# New logging infrastructure (Phase 1 & 3)
# ---------------------------------------------------------------------------
LOGGER_NAME = "qbit-maid"
DEFAULT_MAX_BYTES = 5_000_000
DEFAULT_BACKUP_COUNT = 3
def setup_logging(use_log: bool, log_path: str, log_level: str,
max_bytes: int = DEFAULT_MAX_BYTES,
backup_count: int = DEFAULT_BACKUP_COUNT) -> logging.Logger:
"""Configure and return the qbit-maid logger.
Args:
use_log: If False, adds a NullHandler and sets level to CRITICAL.
log_path: Path to the log file.
log_level: One of debug, info, warn, warning, error, critical.
max_bytes: Max size of a log file before rotation (default 5 MB).
backup_count: Number of rotated backups to keep (default 3).
Returns:
A configured :class:`logging.Logger` named ``"qbit-maid"``.
"""
logger = logging.getLogger(LOGGER_NAME)
# Make setup idempotent: clear any handlers from a previous call
for handler in list(logger.handlers):
logger.removeHandler(handler)
if not use_log:
logger.addHandler(logging.NullHandler())
logger.setLevel(logging.CRITICAL)
return logger
level_map = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warn": logging.WARNING,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
level = level_map.get(log_level.lower(), logging.INFO)
logger.setLevel(level)
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s",
datefmt="%m/%d/%Y %I:%M:%S %p",
)
# File handler with rotation
file_handler = RotatingFileHandler(
log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Console handler for Docker / cron visibility
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
def log_torrent(logger: logging.Logger, level: int, torrent: dict, message: str = ""):
"""Log a torrent record with structured extra fields.
Args:
logger: The qbit-maid logger instance.
level: Logging level constant (e.g. ``logging.INFO``).
torrent: A dict with torrent fields (hash, name, tracker, tags, etc.).
message: Optional human-readable message.
"""
extra = {
"torrent_hash": torrent.get("infohash_v1", ""),
"torrent_name": (torrent.get("name", "") or "")[:20],
"torrent_tracker": torrent.get("tracker", "") or "",
"torrent_tags": torrent.get("tags", "") or "",
}
logger.log(level, message, extra=extra)
+23 -28
View File
@@ -3,34 +3,32 @@ def tor_processor(self):
If torrent meets criteria for deletion, its infohash_v1 will be appended to self.torrent_hash_delete_list If torrent meets criteria for deletion, its infohash_v1 will be appended to self.torrent_hash_delete_list
""" """
for canidate in self.tracker_list: for canidate in self.tracker_list:
if self.use_log: if self.enable_telemetry:
self.tl.debug(f'---Reviewing canidate: ["{canidate["name"][0:20]}..."] {canidate["infohash_v1"]}---') header = ['state','ratio','tags','added','hash','name','tracker']
row = [canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],canidate['infohash_v1'],canidate["name"][0:20],canidate['tracker']]
write_csv(self.cv,self.telemetry_outfile,header,row)
self.tl.debug(f'---Reviewing canidate: ["{canidate["name"][0:20]}..."] {canidate["infohash_v1"]}---')
if is_downloading(canidate['state']): if is_downloading(canidate['state']):
if self.use_log: self.tl.info(f'["{canidate["name"][0:20]}..."] is still downloading and will be skipped.')
self.tl.info(f'["{canidate["name"][0:20]}..."] is still downloading and will be skipped.')
continue continue
elif is_protected_under_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]): elif is_protected_under_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
if self.use_log: self.tl.debug(f'["{canidate["name"][0:20]}..."] is below a 1.05 ratio({canidate["ratio"]})')
self.tl.debug(f'["{canidate["name"][0:20]}..."] is below a 1.05 ratio({canidate["ratio"]})') if is_old_tor(canidate['time_active'], self.max_age):
if is_old_tor(canidate['added_on'], self.age, self.t.time()): self.tl.debug(f'["{canidate["name"][0:20]}..."] Seconds old: {canidate["time_active"]} Delta: {canidate["time_active"] - self.max_age}')
if self.use_log:
self.tl.debug(f'["{canidate["name"][0:20]}..."] Seconds old: {self.t.time() - self.age - canidate["added_on"]}')
self.torrent_hash_delete_list.append(canidate['infohash_v1']) self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log:
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_protected_over_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
if self.use_log:
self.tl.debug(f'["{canidate["name"][0:20]}..."] is above a 1.05 ratio({canidate["ratio"]}).')
self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log:
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.') self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_protected_over_ratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
self.tl.debug(f'["{canidate["name"][0:20]}..."] is above a 1.05 ratio({canidate["ratio"]}).')
self.torrent_hash_delete_list.append(canidate['infohash_v1'])
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion from the protected list.')
elif is_not_protected_tor(self.tracker_non_protected_tag, canidate["tags"]): elif is_not_protected_tor(self.tracker_non_protected_tag, canidate["tags"]):
self.torrent_hash_delete_list.append(canidate['infohash_v1']) self.torrent_hash_delete_list.append(canidate['infohash_v1'])
if self.use_log: self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion.')
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion.')
else: else:
if self.enable_dragnet: if self.enable_dragnet:
dragnet(self.cv,self.dragnet_outfile,canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],self.age,self.t.time(),canidate['infohash_v1'],canidate["name"][0:20],canidate['tracker']) header = ['state','ratio','tags','added','thash','tname','trname']
row = [canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],canidate['infohash_v1'],canidate["name"][0:20],canidate['tracker']]
write_csv(self.cv,self.dragnet_outfile,header,row)
self.tl.info(f'["{canidate["name"][0:20]}..."] is orphaned.') self.tl.info(f'["{canidate["name"][0:20]}..."] is orphaned.')
self.up_tor_counter += 1 self.up_tor_counter += 1
continue continue
@@ -40,11 +38,10 @@ def tor_delete_tags(self):
self.qbt_client.torrents_delete_tags(tag_list) self.qbt_client.torrents_delete_tags(tag_list)
def tor_delete(self): def tor_delete(self):
"""Remove torrents, will also delete files, this keeps the filesystem clean. """Remove torrents, will also delete files, this keeps the filesystem clean.
Only pass self.torrent_hash_delete_list if you would like to keep the files.""" Only pass self.torrent_hash_delete_list if you would like to keep the files."""
if self.use_log: self.tl.debug('Hash list submitted for deletion:')
self.tl.debug('Hash list submitted for deletion:') self.tl.debug(self.torrent_hash_delete_list)
self.tl.debug(self.torrent_hash_delete_list)
if self.torrent_hash_delete_list: if self.torrent_hash_delete_list:
self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list) self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list)
@@ -56,8 +53,8 @@ def is_protected_under_ratio(torratio, setratio, settag, tortag):
if torratio < float(setratio) and settag in tortag: if torratio < float(setratio) and settag in tortag:
return True return True
def is_old_tor(toradd, setage, currenttime): def is_old_tor(realage, maxage):
if toradd + setage <= currenttime: if realage >= maxage:
return True return True
def is_protected_over_ratio(torratio, setratio, settag, tortag): def is_protected_over_ratio(torratio, setratio, settag, tortag):
@@ -68,9 +65,7 @@ def is_not_protected_tor(setnonprotectedtag, tortags):
if setnonprotectedtag in tortags: if setnonprotectedtag in tortags:
return True return True
def dragnet(csv_obj,outfile,state,ratio,tags,added,age,time,thash,tname,trname): def write_csv(csv_obj,outfile,header,row):
header = ['state','ratio','tags','added','age','time','thash','tname','trname']
row = [state,ratio,tags,added,age,time,thash,tname,trname]
with open(outfile, 'a+', encoding='UTF8', newline='') as f: with open(outfile, 'a+', encoding='UTF8', newline='') as f:
writer = csv_obj.writer(f) writer = csv_obj.writer(f)
if f.tell() == 0: if f.tell() == 0:
-21
View File
@@ -1,21 +0,0 @@
from qprocess import dragnet
import csv
import unittest
class TestDragnet(unittest.TestCase):
def test_dragnet_sanity(self):
self.cv = csv
outfile = './test_outfile.csv'
state = 'downloading'
ratio = 1.05
tags = 'ipt'
added = 1
age = 240000
time = 123456
thash = 'asfasdf23412adfqwer'
tname = 'thisismynamehahahah'
trname = 'https://localhost.stackoverflow.tech/317332f1c125bc9c1b9b14fb8e054908/announce'
dragnet(self.cv,outfile,state,ratio,tags,added,age,time,thash,tname,trname)
if __name__ == '__main__':
unittest.main()
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Tests for .gitea/scripts/komodo-deploy.sh.
Runs the real script against a local mock of the Komodo Core API, so no
network access or live Komodo instance is required.
Run with: python3 -m unittest -v test_komodo_deploy
"""
import json
import os
import socket
import subprocess
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(REPO_ROOT, ".gitea", "scripts", "komodo-deploy.sh")
def _free_port():
"""Return a port that is currently closed, for the unreachable-host test."""
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class MockKomodo:
"""Minimal mock of the Komodo Core endpoints used by the deploy script.
``responses`` is a list of ``(status, body)`` pairs consumed in order. A
single-element list repeats forever, which is what the timeout test needs.
Every request is recorded in ``requests`` for assertions.
"""
def __init__(self, responses):
self.responses = list(responses)
self.requests = []
mock = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode() if length else ""
mock.requests.append(
{
"path": self.path,
"key": self.headers.get("x-api-key"),
"secret": self.headers.get("x-api-secret"),
"body": raw,
}
)
status, body = (
mock.responses.pop(0)
if len(mock.responses) > 1
else mock.responses[0]
)
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
self.server = HTTPServer(("127.0.0.1", 0), Handler)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.server.shutdown()
self.server.server_close()
@property
def url(self):
return "http://127.0.0.1:%d" % self.port
def run_script(env_overrides, timeout=60):
"""Run the deploy script with a clean KOMODO_* environment."""
env = {k: v for k, v in os.environ.items() if not k.startswith("KOMODO_")}
env.update(env_overrides)
return subprocess.run(
["sh", SCRIPT],
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
def base_env(url, **extra):
env = {
"KOMODO_URL": url,
"KOMODO_API_KEY": "test-key",
"KOMODO_API_SECRET": "test-secret",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_WAIT_INTERVAL": "1",
}
env.update(extra)
return env
class TestKomodoDeployScript(unittest.TestCase):
# --- target selection -------------------------------------------------
def test_skips_when_target_empty(self):
r = run_script({"KOMODO_TARGET": ""})
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("skipping", r.stdout)
def test_fails_when_target_empty_and_required(self):
r = run_script({"KOMODO_TARGET": "", "KOMODO_REQUIRED": "true"})
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_REQUIRED=true", r.stderr)
def test_fails_when_url_missing(self):
r = run_script({"KOMODO_TARGET": "qbit-maid"})
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_URL is required", r.stderr)
def test_fails_when_api_key_missing(self):
r = run_script(
{
"KOMODO_URL": "http://127.0.0.1:1",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_API_SECRET": "s",
}
)
self.assertEqual(r.returncode, 1)
self.assertIn("KOMODO_API_KEY is required", r.stderr)
def test_invalid_target_type(self):
r = run_script(
{
"KOMODO_URL": "http://127.0.0.1:1",
"KOMODO_API_KEY": "k",
"KOMODO_API_SECRET": "s",
"KOMODO_TARGET": "qbit-maid",
"KOMODO_TARGET_TYPE": "bogus",
}
)
self.assertEqual(r.returncode, 1)
self.assertIn("must be 'stack' or 'procedure'", r.stderr)
# --- request shape ----------------------------------------------------
def test_sends_auth_headers_and_stack_body(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
first = mock.requests[0]
self.assertEqual(first["path"], "/execute/DeployStack")
self.assertEqual(first["key"], "test-key")
self.assertEqual(first["secret"], "test-secret")
self.assertEqual(json.loads(first["body"]), {"stack": "qbit-maid"})
def test_procedure_target_type(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url, KOMODO_TARGET_TYPE="procedure"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(mock.requests[0]["path"], "/execute/RunProcedure")
self.assertEqual(json.loads(mock.requests[0]["body"]), {"procedure": "qbit-maid"})
# --- success and failure ---------------------------------------------
def test_success_path(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("completed successfully", r.stdout)
def test_deploy_failure_surfaces_logs(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(
200,
{
"status": "Complete",
"success": False,
"logs": [{"stage": "Deploy", "msg": "image pull failed"}],
},
),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("failed", r.stderr)
self.assertIn("image pull failed", r.stderr)
def test_unauthorized(self):
with MockKomodo([(401, {"error": "bad credentials"})]) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("HTTP 401", r.stderr)
def test_bad_request(self):
with MockKomodo([(400, {"error": "stack not found"})]) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 1)
self.assertIn("HTTP 400", r.stderr)
def test_unreachable_host(self):
r = run_script(base_env("http://127.0.0.1:%d" % _free_port()))
self.assertEqual(r.returncode, 1)
self.assertIn("Could not reach Komodo", r.stderr)
# --- polling ----------------------------------------------------------
def test_no_wait_returns_after_accept(self):
with MockKomodo([(200, {"_id": {"$oid": "u1"}})]) as mock:
r = run_script(base_env(mock.url, KOMODO_WAIT="false"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("not waiting", r.stdout)
self.assertEqual(len(mock.requests), 1)
def test_polls_until_complete(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "Queued"}),
(200, {"status": "InProgress"}),
(200, {"status": "Complete", "success": True}),
]
) as mock:
r = run_script(base_env(mock.url))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Queued", r.stdout)
self.assertIn("InProgress", r.stdout)
self.assertIn("completed successfully", r.stdout)
self.assertEqual(len(mock.requests), 4)
def test_timeout(self):
with MockKomodo(
[
(200, {"_id": {"$oid": "u1"}}),
(200, {"status": "InProgress"}),
]
) as mock:
r = run_script(
base_env(mock.url, KOMODO_WAIT_TIMEOUT="2", KOMODO_WAIT_INTERVAL="1")
)
self.assertEqual(r.returncode, 1)
self.assertIn("Timed out", r.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)
+21 -7
View File
@@ -1,11 +1,14 @@
import unittest import unittest
import requests as r
from qlist import is_preme,is_cat_ignored,is_tracker_blank,is_protected_tracker,is_not_protected_tracker,is_tag_blank,is_ignored_tag from qlist import is_preme,is_cat_ignored,is_tracker_blank,is_protected_tracker,is_not_protected_tracker,is_tag_blank,is_ignored_tag
from qprocess import is_downloading,is_protected_under_ratio,is_old_tor,is_protected_over_ratio,is_not_protected_tor from qprocess import is_downloading,is_protected_under_ratio,is_old_tor,is_protected_over_ratio,is_not_protected_tor
from qlogging import send_ping
class TestQbitmaid(unittest.TestCase): class TestQbitmaid(unittest.TestCase):
def test_ispreme_sanity(self): def test_ispreme_sanity(self):
self.assertTrue(is_preme(1,1,1)) self.assertTrue(is_preme(1,1))
self.assertFalse(is_preme(1,1,3)) self.assertTrue(is_preme(1,2))
self.assertFalse(is_preme(2,1))
def test_ispreme(self): def test_ispreme(self):
pass pass
@@ -15,7 +18,7 @@ class TestQbitmaid(unittest.TestCase):
self.assertTrue(is_cat_ignored('b', ['a','b','c'])) self.assertTrue(is_cat_ignored('b', ['a','b','c']))
self.assertTrue(is_cat_ignored('c', ['a','b','c'])) self.assertTrue(is_cat_ignored('c', ['a','b','c']))
self.assertFalse(is_cat_ignored('d', ['a','b','c'])) self.assertFalse(is_cat_ignored('d', ['a','b','c']))
self.assertFalse(is_cat_ignored(1, ['a','b','c'])) self.assertFalse(is_cat_ignored(1, ['a','b','d']))
self.assertFalse(is_cat_ignored(1.0000000, ['a','b','c'])) self.assertFalse(is_cat_ignored(1.0000000, ['a','b','c']))
def test_iscatignored(self): def test_iscatignored(self):
@@ -68,12 +71,12 @@ class TestQbitmaid(unittest.TestCase):
pass pass
def test_isoldtor_sanity(self): def test_isoldtor_sanity(self):
self.assertTrue(is_old_tor(1,2,4)) self.assertFalse(is_old_tor(1,2))
def test_isoldtor(self): def test_isoldtor(self):
self.assertFalse(is_old_tor(1661150664,2419200,1662049004.2101078)) self.assertTrue(is_old_tor(1,1))
self.assertFalse(is_old_tor(1661150664,2419200,1662049004)) self.assertTrue(is_old_tor(2,1))
self.assertFalse(is_old_tor(1661150664.000000,2419200.0000000,1662049004.2101078)) self.assertFalse(is_old_tor(1,2))
def test_isprotectedoverratio_sanity(self): def test_isprotectedoverratio_sanity(self):
self.assertTrue(is_protected_over_ratio(2,1,'a','a,b,c')) self.assertTrue(is_protected_over_ratio(2,1,'a','a,b,c'))
@@ -93,6 +96,17 @@ class TestQbitmaid(unittest.TestCase):
def test_isignoredtag(self): def test_isignoredtag(self):
self.assertTrue(is_ignored_tag(['save'], 'save,public,ipt')) self.assertTrue(is_ignored_tag(['save'], 'save,public,ipt'))
def test_sendpingstart_sanity(self):
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb" + "/start")
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb"
send_ping(self, r, url)
def test_sendping_start(self):
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb/"
send_ping(self, r, url.strip("/") + "/start")
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb")
# def test__sanity(self): # def test__sanity(self):
# pass # pass
+41
View File
@@ -0,0 +1,41 @@
from qprocess import write_csv
import csv
import unittest
import os
# Torrent Items needed for testing
# canidate['state'] {canidate["ratio"]} {torrent["tags"]} torrent["seeding_time"] {torrent["category"]} {torrent["name"][0:20]} {canidate["time_active"]}
class TestWriteCSV(unittest.TestCase):
def test_write_csv_dragnet(self):
self.cv = csv
outfile = './test_dragnet_outfile.csv'
state = 'downloading'
ratio = 1.05
tags = 'ipt'
added = 1
age = 240000
time = 123456
thash = 'asfasdf23412adfqwer'
tname = 'thisismynamehahahah'
trname = 'https://localhost.stackoverflow.tech/317332f1c125bc9c1b9b14fb8e054908/announce'
header = ['state','ratio','tags','added','age','time','thash','tname','trname']
row = [state,ratio,tags,added,age,time,thash,tname,trname]
write_csv(self.cv,outfile,header,row)
self.assertTrue(os.path.exists(outfile))
def test_write_csv_telemetry(self):
self.cv = csv
outfile = './test_telemetry_outfile.csv'
state = 'downloading'
ratio = 1.05
tags = 'ipt'
added = 1
thash = 'asfasdf23412adfqwer'
tname = 'thisismynamehahahah'
trname = 'https://localhost.stackoverflow.tech/317332f1c125bc9c1b9b14fb8e054908/announce'
header = ['state','ratio','tags','added','hash','name','tracker']
row = [state,ratio,tags,added,thash,tname,trname]
write_csv(self.cv,outfile,header,row)
self.assertTrue(os.path.exists(outfile))
if __name__ == '__main__':
unittest.main()