46 Commits
Author SHA1 Message Date
jblu 1caa346a7c Merge pull request 'feat(notify): configurable plain/html summary formatting' (#64) from dev-notification-format into main
ci / docker (push) Successful in 1m16s
test / komodo-deploy-script (push) Successful in 1m31s
Reviewed-on: #64
2026-11-02 01:13:22 -06:00
nanobot 881765cdf5 feat(notify): configurable plain/html summary formatting
test / komodo-deploy-script (push) Successful in 17s
test / komodo-deploy-script (pull_request) Successful in 16s
ci / docker (push) Successful in 2m2s
Rework the Pushover/Apprise summary so it reads as a report instead of a
dump of counters.

- Extract build_summary() as the single source of truth for both notifiers,
  removing the duplicated body string and the stray leading indentation
  caused by backslash line continuations.
- Add a 'format' option: 'plain' (aligned columns) or 'html' (bold labels,
  sets html=1 for Pushover). Apprise always receives the plain body.
- Add 'use_emoji' for emoji-prefixed rows.
- Title now states the outcome ('7 torrents removed') instead of
  '--- qbit-maid summary ---'.
- Say 'Would remove' when delete_torrents is false, so a dry run is never
  mistaken for a real deletion.
- Format runtime as 12s / 1m 04s instead of a timedelta with microseconds.
- Add include_hostname, notify_on_empty, priority, sound, retry and expire.
- Log notification failures instead of letting them abort the run.
- Cover the new formatting with unit tests; document the options in the
  README and config.toml.example.
2026-10-27 10:09:35 -05:00
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 1593 additions and 228 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"]
+156 -78
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,171 @@ 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>"
# Notification formatting
# format: "plain" (aligned text) or "html" (bold labels, Pushover HTML)
format = "plain"
use_emoji = false
include_hostname = false
notify_on_empty = true
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
# priority = 0
# sound = "pushover"
# retry = 60
# expire = 3600
[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) | Log lines include the timestamp, level, logger name, function, and line number,
| protected_tag | used to mark torrents to handle with care | for example:
| 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:
Config.json
``` ```
{ 10/26/2026 10:10:00 PM [INFO] qbit-maid:tor_processor:24 - Submitted ["My Torrent..."] for deletion.
"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. ## Notifications
Pushover and Apprise share one summary builder, so both report the same
numbers. The title states the outcome, the body gives the scanned total, the
actionable counters, then the tracker split.
`format = "plain"` (default) produces aligned text:
``` ```
{ qbit-maid: 7 torrents removed
"example": "general",
"example2": "sonarr" Scanned 42 torrents in 12s
}
Removed 7
Orphaned 4
Ignored 5
Premature 3
Protected 10
Non-protected 20
``` ```
You will need a ignored_domains.json in the root directory. This will ignore any torrents from these trackers. `format = "html"` bolds the labels and counts using Pushover's HTML subset
(`html=1` is set automatically). Apprise has no HTML support, so it always
receives the plain body.
Setting `use_emoji = true` switches both styles to `emoji label: value` rows:
``` ```
{ 🗑️ Removed: 7
"iptorrents-empirehost": "ssl.empirehost.me", 👻 Orphaned: 4
"iptorrents-stackoverflow": "localhost.stackoverflow.tech", ⏭️ Ignored: 5
"iptorrents-bgp": "routing.bgp.technology" 🐣 Premature: 3
}
``` ```
You will need a ignored_tags.json in the root directory. This will ignore any torrents with these tags. Other options:
```
{ - `include_hostname` — add a `Host:` line, useful when several instances notify
"first":"first", the same device.
"second":"second", - `notify_on_empty` — set to `false` to skip the push when nothing was removed
"third":"third" or orphaned.
} - `priority` / `sound` — passed through to Pushover. Priority `2` automatically
``` adds `retry` and `expire`.
When `delete_torrents = false` the summary says `Would remove` instead of
`Removed`, so a dry run is never mistaken for a real one.
+31 -14
View File
@@ -1,35 +1,50 @@
[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>"
# Notification formatting
# format: "plain" (aligned text) or "html" (bold labels, Pushover HTML)
format = "plain"
use_emoji = false
include_hostname = false
notify_on_empty = true
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
# priority = 0
# sound = "pushover"
# retry = 60
# expire = 3600
[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 +58,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):
+316 -33
View File
@@ -1,14 +1,7 @@
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""" import socket
if self.use_log: from logging.handlers import RotatingFileHandler
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
@@ -17,16 +10,14 @@ def tor_notify(self):
self.poc = self.po.Pushover(self.po_token) self.poc = self.po.Pushover(self.po_token)
def tor_notify_apprise(self, req_obj, app_obj): def tor_notify_apprise(self, req_obj, app_obj):
"""Use apprise""" """Use apprise. Apprise has no HTML support, so always use the plain style."""
body = f" Total: {self.total_torrents}\n\ opts = get_notify_options(self)
Premature: {self.preme_tor_counter}\n\ title, body = build_summary(
Ignored: {self.ignored_counter}\n\ self,
Protected: {self.c[self.tracker_protected_tag]}\n\ style="plain",
Non-protected: {self.c[self.tracker_non_protected_tag]}\n\ use_emoji=opts["use_emoji"],
Orphaned: {self.up_tor_counter}\n\ include_hostname=opts["include_hostname"],
Marked for deletion: {len(self.torrent_hash_delete_list)}\n\ )
{self.extm}"
title = "--- qbit-maid summary ---"
app_obj(req_obj, self.apprise_host, self.apprise_port, self.apprise_aurls, title, body) app_obj(req_obj, self.apprise_host, self.apprise_port, self.apprise_aurls, title, body)
def tornotifytest(self): def tornotifytest(self):
@@ -49,15 +40,42 @@ def print_processor(self):
self.tl.info(f'Marked for deletion: {len(self.torrent_hash_delete_list)}') self.tl.info(f'Marked for deletion: {len(self.torrent_hash_delete_list)}')
def tor_notify_summary(self): def tor_notify_summary(self):
"""Main notification method when the app is used in an automated fashion""" """Main notification method when the app is used in an automated fashion.
self.poc.message(self.po_key, f" Total: {self.total_torrents}\n\
Premature: {self.preme_tor_counter}\n\ Formatting is controlled by the ``[pushover]`` config section: ``format``
Ignored: {self.ignored_counter}\n\ (plain|html), ``use_emoji``, ``include_hostname``, ``notify_on_empty``,
Protected: {self.c[self.tracker_protected_tag]}\n\ ``priority``, ``sound``, ``retry`` and ``expire``.
Non-protected: {self.c[self.tracker_non_protected_tag]}\n\ """
Orphaned: {self.up_tor_counter}\n\ logger = _logger(self)
Marked for deletion: {len(self.torrent_hash_delete_list)}\n\ opts = get_notify_options(self)
{self.extm}", title="--- qbit-maid summary ---") title, body = build_summary(
self,
style=opts["style"],
use_emoji=opts["use_emoji"],
include_hostname=opts["include_hostname"],
)
if not opts["notify_on_empty"]:
stats = summary_stats(self)
if not stats["removed"] and not stats["orphaned"]:
logger.info("Nothing removed or orphaned, skipping notification.")
return
kwargs = {}
if opts["style"] == "html":
kwargs["html"] = 1
if opts["priority"] is not None:
kwargs["priority"] = opts["priority"]
if opts["priority"] == 2:
kwargs["retry"] = opts["retry"]
kwargs["expire"] = opts["expire"]
if opts["sound"]:
kwargs["sound"] = opts["sound"]
try:
self.poc.message(self.po_key, body, title=title, **kwargs)
except Exception as e: # never let a notification failure abort a run
logger.error(f"Failed to send pushover notification: {e}")
def list_first_tor(self, index=0): def list_first_tor(self, index=0):
"""Only lists the first torrent""" """Only lists the first torrent"""
@@ -79,9 +97,274 @@ 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}]"
# ---------------------------------------------------------------------------
# Notification formatting
# ---------------------------------------------------------------------------
NOTIFY_STYLES = ("plain", "html")
DEFAULT_NOTIFY_STYLE = "plain"
LABEL_WIDTH = 14
VALUE_WIDTH = 4
NOTIFY_EMOJI = {
"removed": "\U0001F5D1\uFE0F",
"orphaned": "\U0001F47B",
"ignored": "\u23ED\uFE0F",
"premature": "\U0001F423",
"protected": "\U0001F6E1\uFE0F",
"non_protected": "\U0001F310",
}
def _logger(self):
"""Return the configured logger, or a bare one for ad-hoc/test use."""
return getattr(self, "tl", logging.getLogger(LOGGER_NAME))
def format_runtime(value):
"""Format a timedelta as ``12s``, ``1m 04s`` or ``1h 02m 03s``."""
try:
total = int(value.total_seconds())
except AttributeError:
return str(value)
if total < 0:
total = 0
hours, remainder = divmod(total, 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes:02d}m {seconds:02d}s"
if minutes:
return f"{minutes}m {seconds:02d}s"
return f"{seconds}s"
def get_notify_options(self):
"""Read notification options from ``[pushover]``, falling back to defaults."""
config = getattr(self, "config", None) or {}
po = config.get("pushover", None) or {}
style = str(po.get("format", DEFAULT_NOTIFY_STYLE) or "").lower()
if style not in NOTIFY_STYLES:
style = DEFAULT_NOTIFY_STYLE
priority = po.get("priority")
return {
"style": style,
"use_emoji": bool(po.get("use_emoji", False)),
"include_hostname": bool(po.get("include_hostname", False)),
"notify_on_empty": bool(po.get("notify_on_empty", True)),
"priority": None if priority is None else int(priority),
"sound": po.get("sound"),
"retry": int(po.get("retry", 60)),
"expire": int(po.get("expire", 3600)),
}
def summary_runtime(self):
"""Runtime string for the summary, with a fallback to ``self.extm``."""
start = getattr(self, "st", None)
end = getattr(self, "et", None)
if start is not None and end is not None:
return format_runtime(end - start)
extm = getattr(self, "extm", "") or ""
if "[" in extm and "]" in extm:
return extm[extm.index("[") + 1:extm.rindex("]")]
return "unknown"
def summary_stats(self):
"""Collect the counters shown in a notification summary."""
counter = getattr(self, "c", None)
protected_tag = getattr(self, "tracker_protected_tag", "") or ""
non_protected_tag = getattr(self, "tracker_non_protected_tag", "") or ""
def tag_count(tag):
if counter is None or not tag:
return 0
try:
return counter[tag]
except (KeyError, TypeError):
return 0
return {
"total": getattr(self, "total_torrents", 0),
"removed": len(getattr(self, "torrent_hash_delete_list", None) or []),
"orphaned": getattr(self, "up_tor_counter", 0),
"ignored": getattr(self, "ignored_counter", 0),
"premature": getattr(self, "preme_tor_counter", 0),
"protected": tag_count(protected_tag),
"non_protected": tag_count(non_protected_tag),
"runtime": summary_runtime(self),
}
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)
def _notify_row(label, value, style, use_emoji, key):
"""Render one label/value line for the chosen style."""
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
if use_emoji:
prefix = f"{emoji} " if emoji else ""
if style == "html":
return f"{prefix}<b>{label}</b>: {value}"
return f"{prefix}{label}: {value}"
if style == "html":
pad = " " * max(LABEL_WIDTH - len(label), 1)
return f"<b>{label}</b>{pad}{value:>{VALUE_WIDTH}}"
return f"{label:<{LABEL_WIDTH}}{value:>{VALUE_WIDTH}}"
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
include_hostname=False):
"""Build the ``(title, body)`` pair used by every notifier.
Result-first layout: the title states what happened, the body gives the
scanned total, the actionable counters, then the tracker split.
Args:
style: ``"plain"`` for aligned text, ``"html"`` for Pushover HTML.
use_emoji: Prefix each row with an emoji marker.
include_hostname: Add a ``Host:`` line above the summary.
Returns:
A ``(title, body)`` tuple of strings.
"""
if style not in NOTIFY_STYLES:
style = DEFAULT_NOTIFY_STYLE
stats = summary_stats(self)
delete_enabled = bool(getattr(self, "delete_torrents", False))
# Title: say what happened. Pushover does not render HTML in titles.
removed = stats["removed"]
noun = "torrent" if removed == 1 else "torrents"
if removed:
action = "removed" if delete_enabled else "would be removed"
title = f"qbit-maid: {removed} {noun} {action}"
elif delete_enabled:
title = "qbit-maid: nothing removed"
else:
title = "qbit-maid: nothing to remove"
lines = []
if include_hostname:
host = socket.gethostname()
if style == "html":
lines.append(f"<b>Host:</b> {host}")
else:
lines.append(f"Host: {host}")
lines.append("")
total = stats["total"]
scanned_noun = "torrent" if total == 1 else "torrents"
if style == "html":
lines.append(f"Scanned <b>{total}</b> {scanned_noun} in <b>{stats['runtime']}</b>")
else:
lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}")
lines.append("")
removed_label = "Removed" if delete_enabled else "Would remove"
lines.append(_notify_row(removed_label, removed, style, use_emoji, "removed"))
lines.append(_notify_row("Orphaned", stats["orphaned"], style, use_emoji, "orphaned"))
lines.append(_notify_row("Ignored", stats["ignored"], style, use_emoji, "ignored"))
lines.append(_notify_row("Premature", stats["premature"], style, use_emoji, "premature"))
lines.append("")
lines.append(_notify_row("Protected", stats["protected"], style, use_emoji, "protected"))
lines.append(_notify_row("Non-protected", stats["non_protected"], style,
use_emoji, "non_protected"))
return title, "\n".join(lines)
# ---------------------------------------------------------------------------
# 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)
+273 -8
View File
@@ -1,11 +1,67 @@
import datetime
import logging
import unittest import unittest
from collections import Counter
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,
build_summary,
format_runtime,
get_notify_options,
summary_stats,
tor_notify_apprise,
tor_notify_summary,
)
class FakeQbt:
"""Minimal stand-in for the Qbt object, holding only what notifiers read."""
def __init__(self, **overrides):
self.config = {"pushover": {}}
self.total_torrents = 42
self.torrent_hash_delete_list = ["a"] * 7
self.up_tor_counter = 4
self.ignored_counter = 5
self.preme_tor_counter = 3
self.tracker_protected_tag = "ipt"
self.tracker_non_protected_tag = "public"
self.c = Counter({"ipt": 10, "public": 20})
self.delete_torrents = True
self.st = datetime.datetime(2026, 10, 27, 9, 54, 0)
self.et = datetime.datetime(2026, 10, 27, 9, 54, 12)
self.po_key = "USERKEY"
self.poc = StubPushover()
self.apprise_host = "localhost"
self.apprise_port = 8088
self.apprise_aurls = "mailto://user:pass@example.com"
self.tl = logging.getLogger("qbit-maid-test")
for key, value in overrides.items():
setattr(self, key, value)
class StubPushover:
"""Records message() calls instead of hitting the Pushover API."""
def __init__(self):
self.calls = []
def message(self, user, body, **kwargs):
self.calls.append({"user": user, "body": body, "kwargs": kwargs})
class FailingPushover:
def message(self, *args, **kwargs):
raise RuntimeError("pushover is down")
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 +71,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 +124,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 +149,215 @@ 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")
# --- notification formatting -------------------------------------------
def test_format_runtime(self):
self.assertEqual(format_runtime(datetime.timedelta(seconds=0)), "0s")
self.assertEqual(format_runtime(datetime.timedelta(seconds=12)), "12s")
self.assertEqual(format_runtime(datetime.timedelta(seconds=64)), "1m 04s")
self.assertEqual(format_runtime(datetime.timedelta(seconds=3723)), "1h 02m 03s")
self.assertEqual(format_runtime(datetime.timedelta(seconds=-5)), "0s")
def test_summary_stats(self):
stats = summary_stats(FakeQbt())
self.assertEqual(stats["total"], 42)
self.assertEqual(stats["removed"], 7)
self.assertEqual(stats["orphaned"], 4)
self.assertEqual(stats["ignored"], 5)
self.assertEqual(stats["premature"], 3)
self.assertEqual(stats["protected"], 10)
self.assertEqual(stats["non_protected"], 20)
self.assertEqual(stats["runtime"], "12s")
def test_summary_stats_missing_attributes(self):
class Bare:
pass
stats = summary_stats(Bare())
self.assertEqual(stats["total"], 0)
self.assertEqual(stats["removed"], 0)
self.assertEqual(stats["runtime"], "unknown")
def test_build_summary_plain(self):
title, body = build_summary(FakeQbt(), style="plain")
self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertEqual(
body,
"Scanned 42 torrents in 12s\n"
"\n"
"Removed 7\n"
"Orphaned 4\n"
"Ignored 5\n"
"Premature 3\n"
"\n"
"Protected 10\n"
"Non-protected 20",
)
def test_build_summary_plain_has_no_leading_indent(self):
_, body = build_summary(FakeQbt(), style="plain")
for line in body.splitlines():
self.assertFalse(line.startswith(" "), f"unexpected indent: {line!r}")
def test_build_summary_html(self):
title, body = build_summary(FakeQbt(), style="html")
self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body)
self.assertIn("<b>Removed</b>", body)
self.assertIn("<b>Non-protected</b>", body)
self.assertNotIn("---", title)
def test_build_summary_emoji(self):
_, plain = build_summary(FakeQbt(), style="plain", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F Removed: 7", plain)
self.assertIn("\U0001F47B Orphaned: 4", plain)
_, html = build_summary(FakeQbt(), style="html", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F <b>Removed</b>: 7", html)
def test_build_summary_delete_disabled(self):
title, body = build_summary(FakeQbt(delete_torrents=False))
self.assertEqual(title, "qbit-maid: 7 torrents would be removed")
self.assertIn("Would remove", body)
self.assertNotIn("Removed", body)
def test_build_summary_nothing_to_do(self):
title, _ = build_summary(FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0))
self.assertEqual(title, "qbit-maid: nothing removed")
title, _ = build_summary(
FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0, delete_torrents=False)
)
self.assertEqual(title, "qbit-maid: nothing to remove")
def test_build_summary_singular(self):
title, _ = build_summary(FakeQbt(torrent_hash_delete_list=["a"], total_torrents=1))
self.assertEqual(title, "qbit-maid: 1 torrent removed")
def test_build_summary_hostname(self):
_, body = build_summary(FakeQbt(), include_hostname=True)
self.assertTrue(body.startswith("Host: "))
def test_build_summary_unknown_style_falls_back(self):
_, body = build_summary(FakeQbt(), style="markdown")
self.assertNotIn("<b>", body)
def test_get_notify_options_defaults(self):
opts = get_notify_options(FakeQbt())
self.assertEqual(opts["style"], "plain")
self.assertFalse(opts["use_emoji"])
self.assertFalse(opts["include_hostname"])
self.assertTrue(opts["notify_on_empty"])
self.assertIsNone(opts["priority"])
self.assertIsNone(opts["sound"])
def test_get_notify_options_overrides(self):
fake = FakeQbt(config={"pushover": {
"format": "HTML",
"use_emoji": True,
"include_hostname": True,
"notify_on_empty": False,
"priority": 2,
"sound": "siren",
"retry": 30,
"expire": 600,
}})
opts = get_notify_options(fake)
self.assertEqual(opts["style"], "html")
self.assertTrue(opts["use_emoji"])
self.assertTrue(opts["include_hostname"])
self.assertFalse(opts["notify_on_empty"])
self.assertEqual(opts["priority"], 2)
self.assertEqual(opts["sound"], "siren")
self.assertEqual(opts["retry"], 30)
self.assertEqual(opts["expire"], 600)
def test_get_notify_options_invalid_style(self):
fake = FakeQbt(config={"pushover": {"format": "bogus"}})
self.assertEqual(get_notify_options(fake)["style"], "plain")
def test_tor_notify_summary_plain_payload(self):
fake = FakeQbt()
tor_notify_summary(fake)
self.assertEqual(len(fake.poc.calls), 1)
call = fake.poc.calls[0]
self.assertEqual(call["user"], "USERKEY")
self.assertEqual(call["kwargs"]["title"], "qbit-maid: 7 torrents removed")
self.assertNotIn("html", call["kwargs"])
def test_tor_notify_summary_html_payload(self):
fake = FakeQbt(config={"pushover": {"format": "html"}})
tor_notify_summary(fake)
call = fake.poc.calls[0]
self.assertEqual(call["kwargs"]["html"], 1)
self.assertIn("<b>", call["body"])
def test_tor_notify_summary_priority_two_adds_retry_expire(self):
fake = FakeQbt(config={"pushover": {"priority": 2, "retry": 30, "expire": 600}})
tor_notify_summary(fake)
kwargs = fake.poc.calls[0]["kwargs"]
self.assertEqual(kwargs["priority"], 2)
self.assertEqual(kwargs["retry"], 30)
self.assertEqual(kwargs["expire"], 600)
def test_tor_notify_summary_priority_one_has_no_retry(self):
fake = FakeQbt(config={"pushover": {"priority": 1}})
tor_notify_summary(fake)
kwargs = fake.poc.calls[0]["kwargs"]
self.assertEqual(kwargs["priority"], 1)
self.assertNotIn("retry", kwargs)
def test_tor_notify_summary_skips_when_empty(self):
fake = FakeQbt(
config={"pushover": {"notify_on_empty": False}},
torrent_hash_delete_list=[],
up_tor_counter=0,
)
tor_notify_summary(fake)
self.assertEqual(fake.poc.calls, [])
def test_tor_notify_summary_sends_when_empty_by_default(self):
fake = FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0)
tor_notify_summary(fake)
self.assertEqual(len(fake.poc.calls), 1)
def test_tor_notify_summary_swallows_send_errors(self):
fake = FakeQbt()
fake.poc = FailingPushover()
tor_notify_summary(fake) # must not raise
def test_tor_notify_apprise_uses_plain_body(self):
captured = {}
def app_obj(req_obj, host, port, aurls, title, body):
captured.update(host=host, port=port, aurls=aurls, title=title, body=body)
fake = FakeQbt(config={"pushover": {"format": "html"}})
tor_notify_apprise(fake, None, app_obj)
self.assertEqual(captured["title"], "qbit-maid: 7 torrents removed")
self.assertNotIn("<b>", captured["body"])
self.assertEqual(captured["port"], 8088)
def test_pushover_and_apprise_bodies_match(self):
fake = FakeQbt()
tor_notify_summary(fake)
captured = {}
tor_notify_apprise(
fake, None,
lambda req, host, port, aurls, title, body: captured.update(title=title, body=body),
)
self.assertEqual(fake.poc.calls[0]["body"], captured["body"])
self.assertEqual(fake.poc.calls[0]["kwargs"]["title"], captured["title"])
# def test__sanity(self): # def test__sanity(self):
# pass # pass
@@ -100,4 +365,4 @@ class TestQbitmaid(unittest.TestCase):
# pass # pass
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+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()