diff --git a/.gitignore b/.gitignore index 6bdb6aa..01e4835 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.log *.json *.csv +*.toml # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/config.json.example b/config.json.example deleted file mode 100644 index eaaaf50..0000000 --- a/config.json.example +++ /dev/null @@ -1,19 +0,0 @@ -{ - "host": "192.168.1.1", - "port": 8080, - "username": "admin", - "password": "admin", - "log_level": "INFO", - "log_path": "./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": true, - "dragnet_outfile": "./dragnet.csv" -} \ No newline at end of file diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..19a33ba --- /dev/null +++ b/config.toml.example @@ -0,0 +1,43 @@ +[qbittorrent] +host = "192.168.4.11" +port = 8085 +username = "jman" +password = "nO^touchy@" + +[logging] +use_log = true +log_level = "DEBUG" +log_path = "./qc.log" + +[app_tags] +protected_tag = "ipt" +non_protected_tag = "public" + +[torrent] +age = 2419200 +minimum_age = 432000 +delete_torrents = true + +[pushover] +use_pushover = false +po_key = "" +po_token = "" + +[dragnet] +enable_dragnet = true +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] +first = "first" +second = "second" +third = "third" \ No newline at end of file diff --git a/ignored_categories.json.example b/ignored_categories.json.example deleted file mode 100644 index 5510278..0000000 --- a/ignored_categories.json.example +++ /dev/null @@ -1,3 +0,0 @@ -{ - "example": "general" -} \ No newline at end of file diff --git a/ignored_domains.json.example b/ignored_domains.json.example deleted file mode 100644 index 516d89b..0000000 --- a/ignored_domains.json.example +++ /dev/null @@ -1,5 +0,0 @@ -{ -"iptorrents-empirehost": "ssl.empirehost.me", -"iptorrents-stackoverflow": "localhost.stackoverflow.tech", -"iptorrents-bgp": "routing.bgp.technology" -} \ No newline at end of file diff --git a/ignored_tags.json.example b/ignored_tags.json.example deleted file mode 100644 index 16477a0..0000000 --- a/ignored_tags.json.example +++ /dev/null @@ -1,5 +0,0 @@ -{ -"first":"first", -"second":"second", -"third":"third" -} \ No newline at end of file diff --git a/pushover.py b/pushover.py new file mode 100644 index 0000000..56c697d --- /dev/null +++ b/pushover.py @@ -0,0 +1,234 @@ +# pushover 1.2 +# +# Copyright (C) 2013-2018 Thibaut Horel + +# 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 . +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) \ No newline at end of file diff --git a/qbit-maid.py b/qbit-maid.py index c9c50e7..42afb98 100644 --- a/qbit-maid.py +++ b/qbit-maid.py @@ -1,6 +1,6 @@ import qbittorrentapi import pushover -from json import load +from tomllib import load from qlist import * from qlogging import * from qprocess import * @@ -15,44 +15,61 @@ class Qbt: """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') + c = open('./config.toml', 'rb') 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 + + # # 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 - # Init 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.log_path = self.config["log_path"] - self.log_level = self.config["log_level"] - 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.minimum_age = self.config["torrent"]["minimum_age"] + self.age = self.config["torrent"]["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"] + + #dragnet + self.enable_dragnet = self.config["dragnet"]["enable_dragnet"] + self.dragnet_outfile = self.config["dragnet"]["dragnet_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"] + # Calling log and notify functions tor_log(self) tor_notify(self) self.t = time # 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 @@ -81,7 +98,7 @@ class Qbt: tor_processor(self) if self.use_log: print_processor(self) - if self.config["delete_torrents"]: + if self.delete_torrents: tor_delete(self) self.et = datetime.datetime.now() get_script_runtime(self) diff --git a/qlogging.py b/qlogging.py index 75a675b..d8415de 100644 --- a/qlogging.py +++ b/qlogging.py @@ -10,11 +10,11 @@ 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 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 process_counts(self): self.c = self.ct() @@ -33,7 +33,7 @@ def print_processor(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\ diff --git a/requirements.txt b/requirements.txt index aabbda0..c0e3491 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -python_pushover==0.4 qbittorrent_api==2022.5.32 \ No newline at end of file diff --git a/test_dragnet.py b/test_dragnet.py index 82f1405..a1fca29 100644 --- a/test_dragnet.py +++ b/test_dragnet.py @@ -1,21 +1,21 @@ -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__': +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() \ No newline at end of file