Compare commits

...

55 Commits
v1.0.0 ... main

Author SHA1 Message Date
d15cc8d95c updated healthchecks to support starting and stopping a job
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-03 20:32:57 -05:00
708d56fc98 Updated documentation
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-05 08:24:28 -05:00
6110dade2d Updated help #56
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-10-05 07:37:21 -05:00
da7951384f Merge pull request 'dev-fix-calculations' (#59) from dev-fix-calculations into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #59
2023-10-05 07:29:00 -05:00
c3865d5fbb Updated test cases
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-04 15:03:11 -05:00
24f6e7b563 updated unit tests and fixed a bug
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-20 00:18:26 -05:00
588382ae14 fixed unit test
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-12 14:03:15 -05:00
01a4c88cc4 Fixed #58
Some checks failed
continuous-integration/drone/push Build is failing
2023-09-12 13:04:38 -05:00
8e70374ae7 test
All checks were successful
continuous-integration/drone/push Build is passing
2023-08-09 10:47:27 -05:00
ce5ae0261c changed drone 2023-08-06 23:38:39 -05:00
4cfb4a88c1 added badge
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-23 00:39:39 -05:00
43c9324a3f updated documentation #56
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-23 00:32:15 -05:00
d2ebddf473 Merge pull request 'updated testing' (#54) from dev-update-testing into main
All checks were successful
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
a4fc54afe5 updated testing
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2023-06-27 12:43:10 -05:00
728e429979 fixed build process
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-23 04:24:14 -05:00
b8a31c43de updated drone
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-23 04:20:12 -05:00
167575fe5d Merge pull request 'migrated to supercronic' (#53) from dev-migrate-supercronic into main
All checks were successful
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
ca1430b302 migrated to supercronic
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-23 04:02:05 -05:00
660a18a70f disable warnings
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-11 14:41:50 -05:00
7a6787888c tag only latest
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-06 15:20:20 -05:00
65598d97ac enabled drone
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-06 15:17:31 -05:00
e2cdca60c3 Merge pull request 'intergrate-drone' (#51) from intergrate-drone into main
All checks were successful
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
978e9326cc updated to execute on main branch
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-06 14:47:03 -05:00
beee95caa4 updated to use secret
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-06 14:44:56 -05:00
6ce2add157 added drone support
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-06 14:33:07 -05:00
dc533a33b1 updated docker ignore #50 2023-06-06 14:18:11 -05:00
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
cf64321e49 added health check support 2023-06-05 23:51:49 -05:00
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
b6346ac335 fixed config init 2023-06-05 22:42:00 -05:00
bd203e9821 Merge pull request 'added docker support' (#43) from implement-docker into main
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/43
2023-06-04 13:14:57 -05:00
8520933f8d added docker support 2023-06-04 13:05:26 -05:00
22198f986c Merge pull request 'added apprise client' (#42) from add-apprise into main
Reviewed-on: https://git.jbranan.com/jblu/qbit-maid/pulls/42
2023-05-16 22:16:14 -05:00
03648f9ae2 added apprise client 2023-05-16 22:05:12 -05:00
Jonathan Branan
9abcdf46b3
Merge pull request #41 from jonbranan/fix-saved-tags
Fix saved tags
2023-03-15 13:42:24 -05:00
b36e27edb9 more adjustments for #33 2023-03-15 13:37:34 -05:00
ecf3da456e #33 and fix saved content getting deleted 2023-03-10 13:22:09 -06:00
abaab2d9cc updated logginglevels 2022-11-01 16:12:49 -05:00
Jonathan Branan
7d4e2a7161
Merge pull request #40 from jonbranan/migration-to-toml
#39 #38 #37 lots of changes, will need toml config
2022-11-01 15:35:29 -05:00
b082680853 #39 #38 #37 lots of changes, will need toml config 2022-11-01 15:33:22 -05:00
Jonathan Branan
1f5f93b452
Merge pull request #35 from jonbranan/add_tracker_data_to_dragnet
#34 added tracker data to dragnet
2022-10-05 13:05:16 -05:00
07aed161eb #34 added tracker data to dragnet 2022-10-05 12:58:23 -05:00
Jonathan Branan
1a5aee5272
Merge pull request #32 from jonbranan/fix_torrent_count
#31 and lots of other changes
2022-10-01 13:10:11 -05:00
8c32f32c85 #31 and lots of other changes 2022-10-01 13:10:42 -05:00
Jonathan Branan
3556382e62
Merge pull request #30 from jonbranan/fix_preme_logging
#28 #26 #25 lots of changes
2022-10-01 12:34:56 -05:00
adc3825396 #28 #26 #25 lots of changes 2022-10-01 12:21:18 -05:00
Jonathan Branan
70b6b3e96a
Merge pull request #29 from jonbranan/add_requirements
added requirements.txt
2022-10-01 10:47:13 -05:00
1fbdd5e84d added requirements.txt 2022-10-01 10:45:19 -05:00
Jonathan Branan
1653c3abce
Merge pull request #24 from jonbranan/fix_dragnet_output
added dragnet test case and fixed dragnet
2022-09-19 20:43:28 -05:00
e8e4690e09 added dragnet test case and fixed dragnet 2022-09-19 20:37:14 -05:00
jonbranan
f0f5b816d8
Merge pull request #23 from jonbranan/add-license-1
Create LICENSE
2022-09-09 14:28:02 -05:00
jonbranan
3a10bbd828
Create LICENSE 2022-09-09 14:27:17 -05:00
jonbranan
2e896aff4d
Merge pull request #20 from jonbranan/standardize_variables
Standardize variables
2022-09-03 19:05:40 -05:00
c116a50c17 fixed additonal variables 2022-09-03 18:55:51 -05:00
4317a33647 standardized the variables #19 2022-09-03 15:02:40 -05:00
21 changed files with 992 additions and 307 deletions

178
.dockerignore Executable file
View File

@ -0,0 +1,178 @@
*.example
LICENSE
*.log
README.md
requirements.txt
Dockerfile
*docker-test*
*.log
*.json
*.csv
*.toml
*.git*
.dockerignore
.DS_Store
.vscode/*
thunder-tests/*
.drone.yml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

55
.drone.yml Normal file
View File

@ -0,0 +1,55 @@
kind: pipeline
name: default
steps:
- name: docker
image: plugins/docker
settings:
registry: git.jonb.io
dry_run: false
username: jblu
password:
from_secret: gittea_drone
repo: git.jonb.io/jblu/qbit-maid
tags:
- latest
when:
branch:
- main
event:
- push
- name: docker-test
image: plugins/docker
settings:
registry: git.jonb.io
dry_run: false
username: jblu
password:
from_secret: gittea_drone
repo: git.jonb.io/jblu/qbit-maid
tags:
- dev
when:
branch:
- dev*
event:
- push
- name: test-main
image: git.jonb.io/jblu/qbit-maid:latest
commands:
- python test_qbitmaid.py
- python test_write_csv.py
when:
branch:
- main
event:
- push
- name: test-dev
image: git.jonb.io/jblu/qbit-maid:dev
commands:
- python test_qbitmaid.py
- python test_write_csv.py
when:
branch:
- dev*
event:
- push

1
.gitignore vendored Normal file → Executable file
View File

@ -1,6 +1,7 @@
*.log
*.json
*.csv
*.toml
# Byte-compiled / optimized / DLL files
__pycache__/

40
AppriseClient.py Executable file
View File

@ -0,0 +1,40 @@
import requests as r
from tomllib import load
import os
def apprise_notify(req_obj, host, port, aurls, title, body):
payload = {'urls': aurls,'title': title,'body': body,}
url = f'http://{host}:{port}/notify/'
apprise_response = req_obj.post(url, json = payload ,verify=False)
return apprise_response
class AppriseClient:
def __init__(self):
self.config = ''
try:
if os.environ["DOCKER"]:
self.host = os.environ["host"]
self.port = os.environ["port"]
self.aurls = os.environ["aurls"]
self.title = os.environ["title"]
self.body = os.environ["body"]
if os.environ["toml_path"]:
config_file_path=os.environ["toml_path"]
with open(config_file_path, 'rb') as c:
self.config = load(c)
except:
KeyError
if os.path.exists('./config.toml'):
config_file_path = './config.toml'
with open(config_file_path, 'rb') as c:
self.config = load(c)
if self.config:
self.host = self.config["apprise"]["host"]
self.port = self.config["apprise"]["port"]
self.aurls = self.config["apprise"]["aurls"]
self.title = self.config["apprise"]["title"]
self.body = self.config["apprise"]["body"]
self.apprise_response = apprise_notify(r,self.host,self.port,self.aurls,self.title,self.body)
if __name__ == "__main__":
AppriseClient()

8
Dockerfile Executable file
View File

@ -0,0 +1,8 @@
FROM python:alpine3.18
WORKDIR /
COPY . opt
RUN apk add --no-cache supercronic
RUN pip install requests
RUN pip install qbittorrent-api
RUN chmod +x /opt/entrypoint.sh
CMD ["/opt/entrypoint.sh"]

21
LICENSE Executable file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Jonathan Logan Branan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

168
README.md Normal file → Executable file
View File

@ -1,6 +1,8 @@
# 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.
# 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.
[![Build Status](https://drone.jonb.io/api/badges/jblu/qbit-maid/status.svg?ref=refs/heads/main)](https://drone.jonb.io/jblu/qbit-maid)
The objective is to remove torrents based on the following criteria:
- tracker domain name
@ -8,94 +10,92 @@ The objective is to remove torrents based on the following criteria:
- ratio
- state
```mermaid
graph TD;
qbit-maid.py-->qlogging.py;
qbit-maid.py-->qlist.py;
qbit-maid.py-->qprocess.py;
qlogging.py-->qbit-maid.py;
qlist.py-->qbit-maid.py;
qprocess.py-->qbit-maid.py;
```
## Install
### Docker(Recommended)
| File | Purpose |
| --- | --- |
| 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 |
[package](https://git.jonb.io/jblu/-/packages/container/qbit-maid/latest)
You will need a config.json in the root directory.
docker pull git.jonb.io/jblu/qbit-maid:latest
| Key | Value |
| --- | --- |
| host | string, ip or hostname of qbittorrent server |
| port | number, port of admin gui(used for api aswell) |
| username | admin account for qbittorrent |
| password | password for admin account |
| loglevel | 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 |
| logpath | 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 |
#### Docker Run Command:
It should look something like this:
Config.json
```
{
"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"
}
```
> Please note it is best practice to escape spaces in variables. That is why there is backslashes in the cron schedule.
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.
```
{
"example": "general",
"example2": "sonarr"
}
```
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
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"
}
```
#### Docker Compose
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"
}
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"
[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>>"
```

View File

@ -1,19 +0,0 @@
{
"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": true,
"use_log": true,
"po_key": "",
"po_token": "",
"delete_torrents": false,
"enable_dragnet": true,
"dragnet_outfile": "./orphaned.csv"
}

51
config.toml.example Executable file
View File

@ -0,0 +1,51 @@
[qbittorrent]
host = "192.168.x.x"
port = 8080
username = "user"
password = "pass"
[logging]
use_log = true
log_level = "INFO"
log_path = "./qc.log"
[app_tags]
protected_tag = "ipt"
non_protected_tag = "public"
[torrent]
max_age = 2419200
min_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"
[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 = true
healthcheck_url = "https://example.com/ping/<uuid>>"

7
entrypoint.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
CRON_CONFIG_FILE="/opt/crontab"
echo "${CRON} python /opt/qbit-maid.py" > $CRON_CONFIG_FILE
exec supercronic -passthrough-logs -quiet $CRON_CONFIG_FILE

View File

@ -1,3 +0,0 @@
{
"example": "general"
}

View File

@ -1,5 +0,0 @@
{
"iptorrents-empirehost": "ssl.empirehost.me",
"iptorrents-stackoverflow": "localhost.stackoverflow.tech",
"iptorrents-bgp": "routing.bgp.technology"
}

View File

@ -1,5 +0,0 @@
{
"first":"first",
"second":"second",
"third":"third"
}

234
pushover.py Executable file
View File

@ -0,0 +1,234 @@
# pushover 1.2
#
# Copyright (C) 2013-2018 Thibaut Horel <thibaut.horel@gmail.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import time
import requests
BASE_URL = "https://api.pushover.net/1/"
MESSAGE_URL = BASE_URL + "messages.json"
USER_URL = BASE_URL + "users/validate.json"
SOUND_URL = BASE_URL + "sounds.json"
RECEIPT_URL = BASE_URL + "receipts/"
GLANCE_URL = BASE_URL + "glances.json"
class RequestError(Exception):
"""Exception which is raised when Pushover's API returns an error code.
The list of errors is stored in the :attr:`errors` attribute.
"""
def __init__(self, errors):
Exception.__init__(self)
self.errors = errors
def __str__(self):
return "\n==> " + "\n==> ".join(self.errors)
class Request(object):
"""Base class to send a request to the Pushover server and check the return
status code. The request is sent on instantiation and raises
a :class:`RequestError` exception when the request is rejected.
"""
def __init__(self, method, url, payload):
files = {}
if "attachment" in payload:
files["attachment"] = payload["attachment"]
del payload["attachment"]
self.payload = payload
self.files = files
request = getattr(requests, method)(url, params=payload, files=files)
self.answer = request.json()
if 400 <= request.status_code < 500:
raise RequestError(self.answer["errors"])
def __str__(self):
return str(self.answer)
class MessageRequest(Request):
"""This class represents a message request to the Pushover API. You do not
need to create it yourself, but the :func:`Pushover.message` function
returns :class:`MessageRequest` objects.
The :attr:`answer` attribute contains a JSON representation of the answer
made by the Pushover API. When sending a message with a priority of 2, you
can poll the status of the notification with the :func:`poll` function.
"""
params = {
"expired": "expires_at",
"called_back": "called_back_at",
"acknowledged": "acknowledged_at",
}
def __init__(self, payload):
Request.__init__(self, "post", MESSAGE_URL, payload)
self.status = {"done": True}
if payload.get("priority", 0) == 2:
self.url = RECEIPT_URL + self.answer["receipt"]
self.status["done"] = False
for param, when in MessageRequest.params.items():
self.status[param] = False
self.status[when] = 0
def poll(self):
"""If the message request has a priority of 2, Pushover keeps sending
the same notification until the client acknowledges it. Calling the
:func:`poll` function fetches the status of the :class:`MessageRequest`
object until the notifications either expires, is acknowledged by the
client, or the callback url is reached. The status is available in the
``status`` dictionary.
Returns ``True`` when the request has expired or been acknowledged and
``False`` otherwise so that a typical handling of a priority-2
notification can look like this::
request = p.message("Urgent!", priority=2, expire=120, retry=60)
while not request.poll():
# do something
time.sleep(5)
print request.status
"""
if not self.status["done"]:
r = Request("get", self.url + ".json", {"token": self.payload["token"]})
for param, when in MessageRequest.params.items():
self.status[param] = bool(r.answer[param])
self.status[when] = int(r.answer[when])
for param in ["acknowledged_by", "acknowledged_by_device"]:
self.status[param] = r.answer[param]
self.status["last_delivered_at"] = int(r.answer["last_delivered_at"])
if any(self.status[param] for param in MessageRequest.params):
self.status["done"] = True
return self.status["done"]
def cancel(self):
"""If the message request has a priority of 2, Pushover keeps sending
the same notification until it either reaches its ``expire`` value or
is aknowledged by the client. Calling the :func:`cancel` function
cancels the notification early.
"""
if not self.status["done"]:
return Request(
"post", self.url + "/cancel.json", {"token": self.payload["token"]}
)
else:
return None
class Pushover(object):
"""This is the main class of the module. It represents a Pushover app and
is tied to a unique API token.
* ``token``: Pushover API token
"""
_SOUNDS = None
message_keywords = [
"title",
"priority",
"sound",
"callback",
"timestamp",
"url",
"url_title",
"device",
"retry",
"expire",
"html",
"attachment",
]
glance_keywords = ["title", "text", "subtext", "count", "percent", "device"]
def __init__(self, token):
self.token = token
@property
def sounds(self):
"""Return a dictionary of sounds recognized by Pushover and that can be
used in a notification message.
"""
if not Pushover._SOUNDS:
request = Request("get", SOUND_URL, {"token": self.token})
Pushover._SOUNDS = request.answer["sounds"]
return Pushover._SOUNDS
def verify(self, user, device=None):
"""Verify that the `user` and optional `device` exist. Returns
`None` when the user/device does not exist or a list of the user's
devices otherwise.
"""
payload = {"user": user, "token": self.token}
if device:
payload["device"] = device
try:
request = Request("post", USER_URL, payload)
except RequestError:
return None
else:
return request.answer["devices"]
def message(self, user, message, **kwargs):
"""Send `message` to the user specified by `user`. It is possible
to specify additional properties of the message by passing keyword
arguments. The list of valid keywords is ``title, priority, sound,
callback, timestamp, url, url_title, device, retry, expire and html``
which are described in the Pushover API documentation.
For convenience, you can simply set ``timestamp=True`` to set the
timestamp to the current timestamp.
An image can be attached to a message by passing a file-like object
to the `attachment` keyword argument.
This method returns a :class:`MessageRequest` object.
"""
payload = {"message": message, "user": user, "token": self.token}
for key, value in kwargs.items():
if key not in Pushover.message_keywords:
raise ValueError("{0}: invalid message parameter".format(key))
elif key == "timestamp" and value is True:
payload[key] = int(time.time())
elif key == "sound" and value not in self.sounds:
raise ValueError("{0}: invalid sound".format(value))
else:
payload[key] = value
return MessageRequest(payload)
def glance(self, user, **kwargs):
"""Send a glance to the user. The default property is ``text``, as this
is used on most glances, however a valid glance does not need to
require text and can be constructed using any combination of valid
keyword properties. The list of valid keywords is ``title, text,
subtext, count, percent and device`` which are described in the
Pushover Glance API documentation.
This method returns a :class:`GlanceRequest` object.
"""
payload = {"user": user, "token": self.token}
for key, value in kwargs.items():
if key not in Pushover.glance_keywords:
raise ValueError("{0}: invalid glance parameter".format(key))
else:
payload[key] = value
return Request("post", GLANCE_URL, payload)

135
qbit-maid.py Normal file → Executable file
View File

@ -1,59 +1,105 @@
#The first file shall contain an client to the qbit api and the processing of the torrents.
import qbittorrentapi
import pushover
from json import load
from tomllib import load
from qlist import *
from qlogging import *
from qprocess import *
from AppriseClient import apprise_notify
import time
import datetime
import logging
from collections import Counter
import csv
import requests as r
import os
r.packages.urllib3.disable_warnings()
class Qbt:
def __init__(self):
"""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
self.st = datetime.datetime.now()
c = open('./config.json')
self.config = load(c)
w = open('./ignored_categories.json')
self.cat_whitelist = load(w)
tg = open('./ignored_tags.json')
self.ignored_tags = load(tg)
# Create the api object
if os.getenv("toml_path"):
config_file_path=os.getenv("toml_path")
with open(config_file_path, 'rb') as c:
self.config = load(c)
if os.path.exists('./config.toml'):
config_file_path = './config.toml'
with open(config_file_path, 'rb') as c:
self.config = load(c)
# # Create the api object
self.qbt_client = qbittorrentapi.Client(
host=self.config["host"],
port=self.config["port"],
username=self.config["username"],
password=self.config["password"],
# qbittorrent
host=self.config["qbittorrent"]["host"],
port=self.config["qbittorrent"]["port"],
username=self.config["qbittorrent"]["username"],
password=self.config["qbittorrent"]["password"],
)
# Create the logging and pushover objects
self.tl = logging
self.po = pushover
self.ct = Counter
self.cv = csv
# Variables torlog uses from config.json
self.use_pushover = self.config["use_pushover"]
self.use_log = self.config["use_log"]
self.po_key = self.config["po_key"]
self.po_token = self.config["po_token"]
self.logpath = self.config["logpath"]
self.loglevel = self.config["loglevel"]
self.tracker_protected_tag = self.config["protected_tag"]
self.tracker_non_protected_tag = self.config["non_protected_tag"]
self.minimum_age = self.config["minimum_age"]
self.age = self.config["age"]
self.enable_dragnet = self.config["enable_dragnet"]
self.dragnet_outfile = self.config["dragnet_outfile"]
# Init config.toml
# logging
self.use_log = self.config["logging"]["use_log"]
self.log_path = self.config["logging"]["log_path"]
self.log_level = self.config["logging"]["log_level"]
#app_tags
self.tracker_protected_tag = self.config["app_tags"]["protected_tag"]
self.tracker_non_protected_tag = self.config["app_tags"]["non_protected_tag"]
#torrent
self.delete_torrents = self.config["torrent"]["delete_torrents"]
self.min_age = self.config["torrent"]["min_age"]
self.max_age = self.config["torrent"]["max_age"]
#pushover
self.use_pushover = self.config["pushover"]["use_pushover"]
self.po_key = self.config["pushover"]["po_key"]
self.po_token = self.config["pushover"]["po_token"]
#apprise
self.use_apprise = self.config["apprise"]["use_apprise"]
self.apprise_host = self.config["apprise"]["host"]
self.apprise_port = self.config["apprise"]["port"]
self.apprise_aurls = self.config["apprise"]["aurls"]
#dragnet
self.enable_dragnet = self.config["dragnet"]["enable_dragnet"]
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
self.cat_whitelist = self.config["ignored_categories"]
#ignored_domains
self.tracker_whitelist = self.config["ignored_domains"]
#ignored_tags
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
torlog(self)
tornotify(self)
tor_log(self)
tor_notify(self)
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
f = open('./ignored_domains.json')
self.tracker_whitelist = load(f)
self.tracker_list = []
self.up_tor_counter = 0
self.preme_tor_counter = 0
@ -70,24 +116,29 @@ class Qbt:
self.tl.exception(e)
self.po.send_message(e, title="qbit-maid API ERROR")
# Pulling all torrent data
self.torrentlist = self.qbt_client.torrents_info()
self.torrent_list = self.qbt_client.torrents_info()
#Main process block
if self.use_log:
listqbitapiinfo(self)
listfirsttor(self)
buildtorlist(self)
processcounts(self)
list_qbit_api_info(self)
list_first_tor(self)
debug_torrent_list(self)
build_tor_list(self)
process_counts(self)
if self.use_log:
torrentcount(self)
torprocessor(self)
torrent_count(self)
tor_processor(self)
if self.use_log:
printprocessor(self)
if self.config["delete_torrents"]:
tordelete(self)
print_processor(self)
if self.delete_torrents:
tor_delete(self)
self.et = datetime.datetime.now()
getscriptruntime(self)
get_script_runtime(self)
if self.use_pushover:
tornotifysummary(self)
tor_notify_summary(self)
if self.use_apprise:
tor_notify_apprise(self, r, apprise_notify)
if self.use_healthcheck:
send_ping(self, r, self.healthcheck_url)
# Run
if __name__== "__main__":
Qbt()

89
qlist.py Normal file → Executable file
View File

@ -1,73 +1,84 @@
def buildtorlist(self):
def build_tor_list(self):
"""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.
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.
"""
self.total_torrents = len(self.torrentlist)
while self.torrentlist:
torrent = self.torrentlist.pop()
self.total_torrents = len(self.torrent_list)
while self.torrent_list:
torrent = self.torrent_list.pop()
if self.use_log:
self.tl.debug(f'["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}')
if isignoredtag(self.ignored_tags.values(),torrent['tags']):
self.ignored_counter += 1
continue
# if torrent['added_on'] + self.minimum_age >= self.t.time():
if ispreme(torrent['added_on'], self.minimum_age, self.t.time()):
self.preme_tor_counter += 1
continue
# if torrent['category'] in self.cat_whitelist.values():
if iscatignored(torrent['category'], self.cat_whitelist.values()):
self.tl.info(f'Ignored torrent:["{torrent["name"][0:20]}..."]')
self.ignored_counter += 1
continue
# if torrent['tracker'] == '':
if istrackerblank(torrent['tracker']):
self.tl.debug(f'---Analyzing ["{torrent["name"][0:20]}..."] {torrent["infohash_v1"]}---')
# Need a way to tag when the tracker is blank
if is_tracker_blank(torrent['tracker']):
if self.use_log:
self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] [{torrent["tracker"]}]hash: {torrent["hash"]}')
self.tl.warning(f'Torrent doesn\'t have a tracker ["{torrent["name"][0:20]}..."] hash: {torrent["hash"]}')
self.ignored_counter += 1
continue
# if torrent['tracker'].split('/')[2] in self.tracker_whitelist.values():
if isprotectedtracker(torrent['tracker'], self.tracker_whitelist.values()):
elif is_cat_ignored(torrent['category'], self.cat_whitelist.values()):
if self.use_log:
self.tl.debug(f'Protected torrent: {torrent["tracker"]}hash: {torrent["hash"]}')
# if torrent['tags'] == '':
if istagblank(torrent['tags']):
self.tl.info(f'Ignored category: ["{torrent["name"][0:20]}..."] category:[{torrent["category"]}] hash: {torrent["hash"]}')
self.ignored_counter += 1
continue
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.ignored_counter += 1
continue
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"]}')
if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
self.tracker_list.append(torrent)
if isnotprotectedtracker(torrent['tracker'], self.tracker_whitelist.values()):
if self.use_log:
self.tl.debug(f'Non-protected torrent: {torrent["tracker"]}hash: {torrent["hash"]}')
# if torrent['tags'] == '':
if istagblank(torrent['tags']):
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
if is_preme(torrent['seeding_time'], self.min_age):
self.preme_tor_counter += 1
self.tl.debug(f'Premature torrent: ["{torrent["name"][0:20]}..."] Seconds Seeded: [{torrent["seeding_time"]}] hash: {torrent["hash"]}')
continue
elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']):
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.tracker_list.append(torrent)
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
if is_tag_blank(torrent['tags']):
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.tracker_list.append(torrent)
def ispreme(added, minage, time):
if added + minage >= time:
def is_preme(seeding_time, minage):
if seeding_time <= minage:
return True
def iscatignored(cat, catlist):
def is_cat_ignored(cat, catlist):
if cat in catlist:
return True
def istrackerblank(tracker):
def is_tracker_blank(tracker):
if tracker == '':
return True
def isprotectedtracker(tracker, trackerlist):
def is_protected_tracker(tracker, trackerlist):
if tracker == '':
return False
if tracker.split('/')[2] in trackerlist:
return True
def isnotprotectedtracker(tracker, trackerlist):
def is_not_protected_tracker(tracker, trackerlist):
if tracker == '':
return False
if tracker.split('/')[2] not in trackerlist:
return True
def istagblank(tag):
def is_tag_blank(tag):
if tag == '':
return True
def isignoredtag(igtags, tortags):
def is_ignored_tag(igtags, tortags):
for igt in igtags:
if igt in tortags:
return True

87
qlogging.py Normal file → Executable file
View File

@ -1,27 +1,44 @@
def torlog(self):
def tor_log(self):
"""Setting up the log file, if self.use_log is set to true and self.loglevel is DEBUG OR INFO"""
if self.use_log:
if self.loglevel == 'DEBUG':
self.tl.basicConfig(filename=self.logpath, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.DEBUG)
elif self.loglevel == 'INFO':
self.tl.basicConfig(filename=self.logpath, format='%(asctime)s:%(levelname)s:%(message)s', encoding='utf-8', datefmt='%m/%d/%Y %I:%M:%S %p',level=self.tl.INFO)
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 tornotify(self):
def tor_notify(self):
"""Seting up to use pushover, if self.use_pushover is set to true and
if valid self.po_key and self.po_token is provided in the config file"""
if self.use_pushover:
self.poc = self.po.Client(self.po_key, api_token=self.po_token)
self.poc = self.po.Pushover(self.po_token)
def tor_notify_apprise(self, req_obj, app_obj):
"""Use apprise"""
body = f" Total: {self.total_torrents}\n\
Premature: {self.preme_tor_counter}\n\
Ignored: {self.ignored_counter}\n\
Protected: {self.c[self.tracker_protected_tag]}\n\
Non-protected: {self.c[self.tracker_non_protected_tag]}\n\
Orphaned: {self.up_tor_counter}\n\
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)
def tornotifytest(self):
"""Used to make sure tornotify is working and messages are getting to the client"""
self.poc.send_message("Test Message", title="qbit-maid")
self.poc.message(self.po_key, "Test Message", title="qbit-maid")
def processcounts(self):
def process_counts(self):
self.c = self.ct()
for item in self.tracker_list:
self.c[item["tags"]] += 1
def printprocessor(self):
def print_processor(self):
"""Print summary of torrents"""
self.tl.info(f'Total: {self.total_torrents}')
self.tl.info(f'Premature: {self.preme_tor_counter}')
@ -31,9 +48,9 @@ def printprocessor(self):
self.tl.info(f'Orphaned: {self.up_tor_counter}')
self.tl.info(f'Marked for deletion: {len(self.torrent_hash_delete_list)}')
def tornotifysummary(self):
def tor_notify_summary(self):
"""Main notification method when the app is used in an automated fashion"""
self.poc.send_message(f" Total: {self.total_torrents}\n\
self.poc.message(self.po_key, f" Total: {self.total_torrents}\n\
Premature: {self.preme_tor_counter}\n\
Ignored: {self.ignored_counter}\n\
Protected: {self.c[self.tracker_protected_tag]}\n\
@ -42,50 +59,38 @@ def tornotifysummary(self):
Marked for deletion: {len(self.torrent_hash_delete_list)}\n\
{self.extm}", title="--- qbit-maid summary ---")
def getunixtimestamp(self):
"""Used for debuging and development related to unixtimestamps, not used in main script but useful"""
self.uts = self.t.time()
self.tl.info(self.uts)
def writetor(self, filepath='./torrentinfo.json'):
"""Write all torrent data to a file.
Useful for development of new features.
"""
pass
def listfirsttor(self, index=0):
def list_first_tor(self, index=0):
"""Only lists the first torrent"""
self.tl.debug('First torrent in the list:')
torrent = self.torrentlist[index]
torrent = self.torrent_list[index]
for k,v in torrent.items():
self.tl.debug(f'{k}: {v}')
self.tl.debug('\n')
def listqbitapiinfo(self):
def list_qbit_api_info(self):
"""Writes torrent info to log file"""
self.tl.debug(f'qBittorrent: {self.qbt_client.app.version}')
self.tl.debug(f'qBittorrent Web API: {self.qbt_client.app.web_api_version}')
def torrentcount(self):
def torrent_count(self):
"""write torrent counts to log file"""
self.tl.debug(f'torrents that are protected {self.tracker_list.count("ipt")}')
self.tl.debug(f'torrents that aren\'t protected {self.tracker_list.count("public")}')
self.tl.debug(f'*** Torrents with tag["{self.tracker_protected_tag}"] {self.c[self.tracker_protected_tag]} ***')
self.tl.debug(f'*** Torrents with tag["{self.tracker_non_protected_tag}"] {self.c[self.tracker_non_protected_tag]} ***')
def torlisttags(self):
pass
def debugpremecal(self):
for torrent in self.torrentlist:
if torrent['infohash_v1'] == 'a89b484ea375094af53ce89ecbea14bf086d6284':
print(torrent["name"][0:20])
print(torrent['added_on'] + self.minimum_age >= self.t.time())
def getscriptruntime(self):
def get_script_runtime(self):
elapsed_time = self.et - self.st
if self.use_log:
self.tl.info(f'Execution time: [{elapsed_time}]')
if self.use_pushover:
self.extm = f"Execution time: [{elapsed_time}]"
if self.use_apprise:
self.extm = f"Execution time: [{elapsed_time}]"
def getobjecttype(object):
print(type(object))
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)

59
qprocess.py Normal file → Executable file
View File

@ -1,83 +1,82 @@
from cgitb import enable
def torprocessor(self):
def tor_processor(self):
"""Main logic to sort through both self.tracker_nonprotected_list and self.tracker_protected_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:
# if canidate['state'] == 'downloading':
if isdownloading(canidate['state']):
if self.enable_telemetry:
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)
if self.use_log:
self.tl.debug(f'---Reviewing canidate: ["{canidate["name"][0:20]}..."] {canidate["infohash_v1"]}---')
if is_downloading(canidate['state']):
if self.use_log:
self.tl.info(f'["{canidate["name"][0:20]}..."] is still downloading and will be skipped.')
continue
# elif canidate['ratio'] < float(1.05) and self.tracker_protected_tag in canidate["tags"]:
elif isprotectedunderratio(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"]})')
# if canidate['added_on'] + self.age <= self.t.time():
if isoldtor(canidate['added_on'], self.age, self.t.time()):
if is_old_tor(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.tl.debug(f'["{canidate["name"][0:20]}..."] Seconds old: {canidate["time_active"]} Delta: {canidate["time_active"] - self.max_age}')
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 canidate['ratio'] >= float(1.05) and self.tracker_protected_tag in canidate["tags"]:
elif isprotectedoverratio(canidate['ratio'], 1.05, self.tracker_protected_tag, canidate["tags"]):
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.')
# elif self.tracker_non_protected_tag in canidate["tags"]:
elif isnonprotectedtor(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'])
if self.use_log:
self.tl.info(f'Submitted ["{canidate["name"][0:20]}..."] for deletion.')
else:
if self.enable_dragnet:
dragnet(self,canidate['state'],canidate['ratio'],canidate["tags"],canidate['added_on'],self.age,self.t.time(),canidate['infohash_v1'],canidate["name"][0:20])
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.up_tor_counter += 1
continue
def tordeletetags(self):
def tor_delete_tags(self):
tag_list = [self.tracker_protected_tag, self.tracker_non_protected_tag]
self.qbt_client.torrents_delete_tags(tag_list)
def tordelete(self):
def tor_delete(self):
"""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."""
if self.use_log:
self.tl.debug('Hash list submitted for deletion:')
self.tl.debug(self.torrent_hash_delete_list)
self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list)
if self.torrent_hash_delete_list:
self.qbt_client.torrents_delete(True, self.torrent_hash_delete_list)
def isdownloading(state):
def is_downloading(state):
if state == 'downloading':
return True
def isprotectedunderratio(torratio, setratio, settag, tortag):
def is_protected_under_ratio(torratio, setratio, settag, tortag):
if torratio < float(setratio) and settag in tortag:
return True
def isoldtor(toradd, setage, currenttime):
if toradd + setage <= currenttime:
def is_old_tor(realage, maxage):
if realage >= maxage:
return True
def isprotectedoverratio(torratio, setratio, settag, tortag):
def is_protected_over_ratio(torratio, setratio, settag, tortag):
if torratio >= float(setratio) and settag in tortag:
return True
def isnonprotectedtor(setnonprotectedtag, tortags):
def is_not_protected_tor(setnonprotectedtag, tortags):
if setnonprotectedtag in tortags:
return True
def dragnet(self,state,ratio,tags,added,age,time,thash,tname):
header = ['state','ratio','tags','added','age','time','thash','tname']
row = [state,ratio,tags,added,age,time,thash,tname]
with open(self.dragnet_outfile, 'w', encoding='UTF8', newline='') as f:
writer = self.cv.writer(f)
def write_csv(csv_obj,outfile,header,row):
with open(outfile, 'a+', encoding='UTF8', newline='') as f:
writer = csv_obj.writer(f)
if f.tell() == 0:
writer.writerow(header)
writer.writerow(row)

1
requirements.txt Executable file
View File

@ -0,0 +1 @@
qbittorrent_api==2022.5.32

92
test_qbitmaid.py Normal file → Executable file
View File

@ -1,97 +1,111 @@
import unittest
from qlist import ispreme,iscatignored,istrackerblank,isprotectedtracker,isnotprotectedtracker,istagblank,isignoredtag
from qprocess import isdownloading,isprotectedunderratio,isoldtor,isprotectedoverratio,isnonprotectedtor
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 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):
def test_ispreme_sanity(self):
self.assertTrue(ispreme(1,1,1))
self.assertFalse(ispreme(1,1,3))
self.assertTrue(is_preme(1,1))
self.assertTrue(is_preme(1,2))
self.assertFalse(is_preme(2,1))
def test_ispreme(self):
pass
def test_iscatignored_sanity(self):
self.assertTrue(iscatignored('a', ['a','b','c']))
self.assertTrue(iscatignored('b', ['a','b','c']))
self.assertTrue(iscatignored('c', ['a','b','c']))
self.assertFalse(iscatignored('d', ['a','b','c']))
self.assertFalse(iscatignored(1, ['a','b','c']))
self.assertFalse(iscatignored(1.0000000, ['a','b','c']))
self.assertTrue(is_cat_ignored('a', ['a','b','c']))
self.assertTrue(is_cat_ignored('b', ['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(1, ['a','b','d']))
self.assertFalse(is_cat_ignored(1.0000000, ['a','b','c']))
def test_iscatignored(self):
pass
def test_istrackerblank_sanity(self):
self.assertTrue(istrackerblank(''))
self.assertFalse(istrackerblank('a'))
self.assertFalse(istrackerblank(1))
self.assertFalse(istrackerblank(1.00000000))
self.assertTrue(is_tracker_blank(''))
self.assertFalse(is_tracker_blank('a'))
self.assertFalse(is_tracker_blank(1))
self.assertFalse(is_tracker_blank(1.00000000))
def test_istrackerblank(self):
pass
def test_isprotectedtracker_sanity(self):
self.assertTrue(isprotectedtracker('https://a.com/',['a.com','b.me','c.io']))
self.assertFalse(isprotectedtracker('https://google.com/',['a.com','b.me','c.io']))
self.assertFalse(isprotectedtracker('https://d.com',['a.com','b.me','c.io']))
self.assertTrue(is_protected_tracker('https://a.com/',['a.com','b.me','c.io']))
self.assertFalse(is_protected_tracker('https://google.com/',['a.com','b.me','c.io']))
self.assertFalse(is_protected_tracker('https://d.com',['a.com','b.me','c.io']))
def test_isprotectedtracker(self):
pass
def test_isnotprotectedtracker_sanity(self):
self.assertFalse(isnotprotectedtracker('https://a.com/',['a.com','b.me','c.io']))
self.assertTrue(isnotprotectedtracker('https://google.com/',['a.com','b.me','c.io']))
self.assertTrue(isnotprotectedtracker('https://d.com',['a.com','b.me','c.io']))
self.assertFalse(is_not_protected_tracker('https://a.com/',['a.com','b.me','c.io']))
self.assertTrue(is_not_protected_tracker('https://google.com/',['a.com','b.me','c.io']))
self.assertTrue(is_not_protected_tracker('https://d.com',['a.com','b.me','c.io']))
def test_isnotprotectedtracker(self):
pass
def test_istagblank(self):
self.assertTrue(istagblank(''))
self.assertFalse(istagblank('a'))
self.assertFalse(istagblank(1))
self.assertFalse(istagblank(1.0001))
self.assertFalse(istagblank(False))
self.assertFalse(istagblank(True))
self.assertTrue(is_tag_blank(''))
self.assertFalse(is_tag_blank('a'))
self.assertFalse(is_tag_blank(1))
self.assertFalse(is_tag_blank(1.0001))
self.assertFalse(is_tag_blank(False))
self.assertFalse(is_tag_blank(True))
def test_isdownloading_sanity(self):
self.assertTrue(isdownloading('downloading'))
self.assertTrue(is_downloading('downloading'))
def test_isdownloading(self):
self.assertFalse(isdownloading('DOWNLOADING'))
self.assertFalse(isdownloading('dOwNlOaDiNg'))
self.assertFalse(is_downloading('DOWNLOADING'))
self.assertFalse(is_downloading('dOwNlOaDiNg'))
def test_isprotectedunderratio_sanity(self):
self.assertTrue(isprotectedunderratio(0.5,1,'a','a,b,c'))
self.assertTrue(is_protected_under_ratio(0.5,1,'a','a,b,c'))
def test_isprotectedunderratio(self):
pass
def test_isoldtor_sanity(self):
self.assertTrue(isoldtor(1,2,4))
self.assertFalse(is_old_tor(1,2))
def test_isoldtor(self):
self.assertFalse(isoldtor(1661150664,2419200,1662049004.2101078))
self.assertFalse(isoldtor(1661150664,2419200,1662049004))
self.assertFalse(isoldtor(1661150664.000000,2419200.0000000,1662049004.2101078))
self.assertTrue(is_old_tor(1,1))
self.assertTrue(is_old_tor(2,1))
self.assertFalse(is_old_tor(1,2))
def test_isprotectedoverratio_sanity(self):
self.assertTrue(isprotectedoverratio(2,1,'a','a,b,c'))
self.assertTrue(is_protected_over_ratio(2,1,'a','a,b,c'))
def test_isprotectedoverratio(self):
pass
def test_isnonprotectedtor_sanity(self):
self.assertTrue(isnonprotectedtor('a','a,b,c'))
self.assertTrue(is_not_protected_tor('a','a,b,c'))
def test_isnonprotectedtor(self):
pass
def test_isignoredtag_sanity(self):
self.assertTrue(isignoredtag(['a','b','c'], 'first,second,third,a'))
self.assertTrue(is_ignored_tag(['a','b','c'], 'first,second,third,a'))
def test_isignoredtag_sanity(self):
self.assertTrue(isignoredtag(['a','b','c'], 'first,second,third,a'))
def test_isignoredtag(self):
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):
# pass

41
test_write_csv.py Normal file
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()