diff --git a/README.md b/README.md index c8bbf39..2ffe8e3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This is a program used to manage your qBittorrent instance such as: * Tag any torrents that have no hard links and allows optional cleanup to delete these torrents and contents based on maximum ratio and/or time seeded * RecycleBin function to move files into a RecycleBin folder instead of deleting the data directly when deleting a torrent * Built-in scheduler to run the script every x minutes. (Can use `--run` command to run without the scheduler) +* Webhook notifications with [Notifiarr](https://notifiarr.com/) and [Apprise API](https://github.com/caronc/apprise-api) integration ## Getting Started Check out the [wiki](https://github.com/StuffAnThings/qbit_manage/wiki) for installation help diff --git a/config/config.yml.sample b/config/config.yml.sample index 09ad710..ad4e93d 100644 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -128,16 +128,25 @@ orphaned: - "**/@eaDir" - "/data/torrents/temp/**" +#Apprise integration with webhooks +apprise: + #Mandatory to fill out the url of your apprise API endpoint + api_url: http://apprise-api:8000 + #Mandatory to fill out the notification url/urls based on the notification services provided by apprise. https://github.com/caronc/apprise/wiki + notify_url: + #Notifiarr integration with webhooks notifiarr: #Mandatory to fill out API Key apikey: #################################### - #Your qBittorrent instance, can be set to any unique value + # Set to a unique value (could be your username on notifiarr for example) instance: - test: true - develop: true -# Webhook notifications: Set value to notifiarr if using notifiarr integration, otherwise set to webhook URL +# Webhook notifications: +# Possible values: +# Set value to notifiarr if using notifiarr integration +# Set value to apprise if using apprise integration +# Set value to a valid webhook URL webhooks: error: notifiarr run_start: notifiarr diff --git a/modules/apprise.py b/modules/apprise.py new file mode 100644 index 0000000..333cb13 --- /dev/null +++ b/modules/apprise.py @@ -0,0 +1,14 @@ +import logging + +from modules.util import Failed + +logger = logging.getLogger("qBit Manage") + +class Apprise: + def __init__(self, config, params): + self.config = config + self.api_url = params["api_url"] + self.notify_url = ",".join(params["notify_url"]) + response = self.config.get(self.api_url) + if response.status_code != 200: + raise Failed(f"Apprise Error: Unable to connect to Apprise using {self.api_url}") \ No newline at end of file diff --git a/modules/config.py b/modules/config.py index 6a718f7..20c51a9 100644 --- a/modules/config.py +++ b/modules/config.py @@ -4,6 +4,7 @@ from modules.util import Failed, check from modules.qbittorrent import Qbt from modules.webhooks import Webhooks from modules.notifiarr import Notifiarr +from modules.apprise import Apprise from ruamel import yaml from retrying import retry @@ -38,6 +39,7 @@ class Config: if "nohardlinks" in new_config: new_config["nohardlinks"] = new_config.pop("nohardlinks") if "recyclebin" in new_config: new_config["recyclebin"] = new_config.pop("recyclebin") if "orphaned" in new_config: new_config["orphaned"] = new_config.pop("orphaned") + if "apprise" in new_config: new_config["apprise"] = new_config.pop("apprise") if "notifiarr" in new_config: new_config["notifiarr"] = new_config.pop("notifiarr") if "webhooks" in new_config: temp = new_config.pop("webhooks") @@ -75,6 +77,22 @@ class Config: "function": self.util.check_for_attribute(self.data, "function", parent="webhooks", var_type="list", default_is_none=True) } + self.AppriseFactory = None + if "apprise" in self.data: + if self.data["apprise"] is not None: + logger.info("Connecting to Apprise...") + try: + self.AppriseFactory = Apprise(self, { + "api_url": self.util.check_for_attribute(self.data, "api_url", parent="apprise", var_type="url", throw=True), + "notify_url": self.util.check_for_attribute(self.data, "notify_url", parent="apprise", var_type="list", throw=True), + }) + except Failed as e: + logger.error(e) + logger.info(f"Apprise Connection {'Failed' if self.AppriseFactory is None else 'Successful'}") + else: + logger.warning("Config Warning: apprise attribute not found") + + self.NotifiarrFactory = None if "notifiarr" in self.data: if self.data["notifiarr"] is not None: @@ -92,7 +110,7 @@ class Config: else: logger.warning("Config Warning: notifiarr attribute not found") - self.Webhooks = Webhooks(self, self.webhooks, notifiarr=self.NotifiarrFactory) + self.Webhooks = Webhooks(self, self.webhooks, notifiarr=self.NotifiarrFactory,apprise=self.AppriseFactory) try: self.Webhooks.start_time_hooks(self.start_time) except Failed as e: @@ -267,16 +285,18 @@ class Config: if not dry_run: os.remove(file) if num_del > 0: if not dry_run: util.remove_empty_directories(self.recycle_dir,"**/*") + body = [] + body += util.print_multiline(n_info,loglevel) + body += util.print_line(f"{'Did not delete' if dry_run else 'Deleted'} {num_del} files ({util.human_readable_size(size_bytes)}) from the Recycle Bin.",loglevel) attr = { "function":"empty_recyclebin", "title":f"Emptying Recycle Bin (Files > {self.recyclebin['empty_after_x_days']} days)", + "body": "\n".join(body), "files":files, "empty_after_x_days": self.recyclebin['empty_after_x_days'], "size_in_bytes":size_bytes } self.send_notifications(attr) - util.print_multiline(n_info,loglevel) - util.print_line(f"{'Did not delete' if dry_run else 'Deleted'} {num_del} files ({util.human_readable_size(size_bytes)}) from the Recycle Bin.",loglevel) else: logger.debug('No files found in "' + self.recycle_dir + '"') return num_del diff --git a/modules/notifiarr.py b/modules/notifiarr.py index a2aeed9..f598b70 100644 --- a/modules/notifiarr.py +++ b/modules/notifiarr.py @@ -28,6 +28,8 @@ class Notifiarr: url = f"{dev_url if self.develop else base_url}{'notification/test' if self.test else f'{path}{self.apikey}'}" if self.config.trace_mode: logger.debug(url.replace(self.apikey, "APIKEY")) - test_payload = (f"qbitManage-{self.apikey[:5]}") - params = {"event": test_payload, "qbit_client":self.config.data["qbt"]["host"], "instance":self.instance if self.test else "notify"} + if self.test: + params = {"event": f"qbitManage-{self.apikey[:5]}", "qbit_client":self.config.data["qbt"]["host"], "instance":self.instance} + else: + params = {"qbit_client":self.config.data["qbt"]["host"], "instance":self.instance} return url, params \ No newline at end of file diff --git a/modules/qbittorrent.py b/modules/qbittorrent.py index 6eda1ba..8583660 100644 --- a/modules/qbittorrent.py +++ b/modules/qbittorrent.py @@ -104,12 +104,14 @@ class Qbt: new_cat = self.config.get_category(torrent.save_path) tags = self.config.get_tags([x.url for x in torrent.trackers if x.url.startswith('http')]) if not dry_run: torrent.set_category(category=new_cat) - print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) - print_line(util.insert_space(f'New Category: {new_cat}',3),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) + body = [] + body += print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) + body += print_line(util.insert_space(f'New Category: {new_cat}',3),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) attr = { "function":"cat_update", "title":"Updating Categories", + "body": "\n".join(body), "torrent_name":torrent.name, "torrent_new_cat": new_cat, "torrent_tracker": tags["url"], @@ -134,13 +136,15 @@ class Qbt: tags = self.config.get_tags([x.url for x in torrent.trackers if x.url.startswith('http')]) if tags["new_tag"]: num_tags += 1 - print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) - print_line(util.insert_space(f'New Tag: {tags["new_tag"]}',8),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) - self.set_tags_and_limits(torrent, tags["max_ratio"], tags["max_seeding_time"],tags["limit_upload_speed"],tags["new_tag"]) + body = [] + body += print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) + body += print_line(util.insert_space(f'New Tag: {tags["new_tag"]}',8),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) + body.extend(self.set_tags_and_limits(torrent, tags["max_ratio"], tags["max_seeding_time"],tags["limit_upload_speed"],tags["new_tag"])) attr = { "function":"tag_update", "title":"Updating Tags", + "body": "\n".join(body), "torrent_name":torrent.name, "torrent_new_tag": tags["new_tag"], "torrent_tracker": tags["url"], @@ -158,21 +162,22 @@ class Qbt: def set_tags_and_limits(self,torrent,max_ratio,max_seeding_time,limit_upload_speed=None,tags=None): dry_run = self.config.args['dry_run'] - loglevel = 'DRYRUN' if dry_run else 'INFO' + loglevel = 'DRYRUN' if dry_run else 'INFO' + body = [] #Print Logs if limit_upload_speed: - if limit_upload_speed == -1: print_line(util.insert_space(f'Limit UL Speed: Infinity',1),loglevel) - else: print_line(util.insert_space(f'Limit UL Speed: {limit_upload_speed} kB/s',1),loglevel) + if limit_upload_speed == -1: body += print_line(util.insert_space(f'Limit UL Speed: Infinity',1),loglevel) + else: body += print_line(util.insert_space(f'Limit UL Speed: {limit_upload_speed} kB/s',1),loglevel) if max_ratio or max_seeding_time: - if max_ratio == -2 or max_seeding_time == -2: print_line(util.insert_space(f'Share Limit: Use Global Share Limit',4),loglevel) - elif max_ratio == -1 or max_seeding_time == -1: print_line(util.insert_space(f'Share Limit: Set No Share Limit',4),loglevel) + if max_ratio == -2 or max_seeding_time == -2: body += print_line(util.insert_space(f'Share Limit: Use Global Share Limit',4),loglevel) + elif max_ratio == -1 or max_seeding_time == -1: body += print_line(util.insert_space(f'Share Limit: Set No Share Limit',4),loglevel) else: if max_ratio != torrent.max_ratio and not max_seeding_time: - print_line(util.insert_space(f'Share Limit: Max Ratio = {max_ratio}',4),loglevel) + body += print_line(util.insert_space(f'Share Limit: Max Ratio = {max_ratio}',4),loglevel) elif max_seeding_time != torrent.max_seeding_time and not max_ratio: - print_line(util.insert_space(f'Share Limit: Max Seed Time = {max_seeding_time} min',4),loglevel) + body += print_line(util.insert_space(f'Share Limit: Max Seed Time = {max_seeding_time} min',4),loglevel) elif max_ratio != torrent.max_ratio and max_seeding_time != torrent.max_seeding_time: - print_line(util.insert_space(f'Share Limit: Max Ratio = {max_ratio}, Max Seed Time = {max_seeding_time} min',4),loglevel) + body += print_line(util.insert_space(f'Share Limit: Max Ratio = {max_ratio}, Max Seed Time = {max_seeding_time} min',4),loglevel) #Update Torrents if not dry_run: if tags: torrent.add_tags(tags) @@ -189,6 +194,7 @@ class Qbt: if not max_ratio: max_ratio = torrent.max_ratio if not max_seeding_time: max_seeding_time = torrent.max_seeding_time torrent.set_share_limits(max_ratio,max_seeding_time) + return body def tag_nohardlinks(self): dry_run = self.config.args['dry_run'] @@ -222,13 +228,15 @@ class Qbt: #Will only tag new torrents that don't have noHL tag if 'noHL' not in torrent.tags : num_tags += 1 - print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) - print_line(util.insert_space(f'Added Tag: noHL',6),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) - self.set_tags_and_limits(torrent, nohardlinks[category]["max_ratio"], nohardlinks[category]["max_seeding_time"],nohardlinks[category]["limit_upload_speed"],tags='noHL') + body = [] + body += print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) + body += print_line(util.insert_space(f'Added Tag: noHL',6),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) + body.extend(self.set_tags_and_limits(torrent, nohardlinks[category]["max_ratio"], nohardlinks[category]["max_seeding_time"],nohardlinks[category]["limit_upload_speed"],tags='noHL')) attr = { "function":"tag_nohardlinks", "title":"Tagging Torrents with No Hardlinks", + "body": "\n".join(body), "torrent_name":torrent.name, "torrent_add_tag": 'noHL', "torrent_tracker": tags["url"], @@ -246,13 +254,18 @@ class Qbt: #Checks to see if previous noHL tagged torrents now have hard links. if (not (util.nohardlink(torrent['content_path'].replace(root_dir,root_dir))) and ('noHL' in torrent.tags)): num_untag += 1 - print_line(f'Previous Tagged noHL Torrent Name: {torrent.name} has hard links found now.',loglevel) - print_line(util.insert_space(f'Removed Tag: noHL',6),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) - print_line(f"{'Not Reverting' if dry_run else 'Reverting'} share limits.",loglevel) + body = [] + body += print_line(f'Previous Tagged noHL Torrent Name: {torrent.name} has hard links found now.',loglevel) + body += print_line(util.insert_space(f'Removed Tag: noHL',6),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) + body += print_line(f"{'Not Reverting' if dry_run else 'Reverting'} share limits.",loglevel) + if not dry_run: + torrent.remove_tags(tags='noHL') + body.extend(self.set_tags_and_limits(torrent, tags["max_ratio"], tags["max_seeding_time"],tags["limit_upload_speed"])) attr = { "function":"untag_nohardlinks", "title":"Untagging Previous Torrents that now have Hard Links", + "body": "\n".join(body), "torrent_name":torrent.name, "torrent_remove_tag": 'noHL', "torrent_tracker": tags["url"], @@ -262,9 +275,6 @@ class Qbt: "torrent_limit_upload_speed": tags["limit_upload_speed"] } self.config.send_notifications(attr) - if not dry_run: - torrent.remove_tags(tags='noHL') - self.set_tags_and_limits(torrent, tags["max_ratio"], tags["max_seeding_time"],tags["limit_upload_speed"]) #loop through torrent list again for cleanup purposes if (nohardlinks[category]['cleanup']): for torrent in torrent_list: @@ -272,27 +282,29 @@ class Qbt: #Double check that the content path is the same before we delete anything if torrent['content_path'].replace(root_dir,root_dir) == tdel_dict[torrent.name]: tags = self.config.get_tags([x.url for x in torrent.trackers if x.url.startswith('http')]) - print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) - print_line(util.insert_space(f"Cleanup: True [No hard links found and meets Share Limits.]",8),loglevel) + body = [] + body += print_line(util.insert_space(f'Torrent Name: {torrent.name}',3),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) + body += print_line(util.insert_space(f"Cleanup: True [No hard links found and meets Share Limits.]",8),loglevel) + if (os.path.exists(torrent['content_path'].replace(root_dir,root_dir))): + if not dry_run: self.tor_delete_recycle(torrent) + del_tor_cont += 1 + attr["torrents_deleted_and_contents"]: True + body += print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) + else: + if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False) + del_tor += 1 + attr["torrents_deleted_and_contents"]: False + body += print_line(util.insert_space(f'Deleted .torrent but NOT content files.',8),loglevel) attr = { "function":"cleanup_tag_nohardlinks", "title":"Removing NoHL Torrents and meets Share Limits", + "body": "\n".join(body), "torrent_name":torrent.name, "cleanup": 'True', "torrent_tracker": tags["url"], "notifiarr_indexer": tags["notifiarr"], } - if (os.path.exists(torrent['content_path'].replace(root_dir,root_dir))): - if not dry_run: self.tor_delete_recycle(torrent) - del_tor_cont += 1 - attr["torrents_deleted_and_contents"]: True - print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) - else: - if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False) - del_tor += 1 - attr["torrents_deleted_and_contents"]: False - print_line(util.insert_space(f'Deleted .torrent but NOT content files.',8),loglevel) self.config.send_notifications(attr) if num_tags >= 1: print_line(f"{'Did not Tag/set' if dry_run else 'Tag/set'} share limits for {num_tags} .torrent{'s.' if num_tags > 1 else '.'}",loglevel) @@ -345,6 +357,7 @@ class Qbt: attr = { "function":"potential_rem_unregistered", "title":"Potential Unregistered Torrents", + "body": pot_unr, "torrent_name":t_name, "torrent_status": msg_up, "torrent_tracker": tags["url"], @@ -352,34 +365,36 @@ class Qbt: } self.config.send_notifications(attr) if any(m in msg_up for m in unreg_msgs) and x.status == 4 and 'DOWN' not in msg_up and 'UNREACHABLE' not in msg_up: - print_line(util.insert_space(f'Torrent Name: {t_name}',3),loglevel) - print_line(util.insert_space(f'Status: {msg_up}',9),loglevel) - print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) - attr = { - "function":"rem_unregistered", - "title":"Removing Unregistered Torrents", - "torrent_name":t_name, - "torrent_status": msg_up, - "torrent_tracker": tags["url"], - "notifiarr_indexer": tags["notifiarr"], - } + body = [] + body += print_line(util.insert_space(f'Torrent Name: {t_name}',3),loglevel) + body += print_line(util.insert_space(f'Status: {msg_up}',9),loglevel) + body += print_line(util.insert_space(f'Tracker: {tags["url"]}',8),loglevel) if t_count > 1: # Checks if any of the original torrents are working if '' in t_msg or 2 in t_status: if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False) attr["torrents_deleted_and_contents"]: False - print_line(util.insert_space(f'Deleted .torrent but NOT content files.',8),loglevel) + body += print_line(util.insert_space(f'Deleted .torrent but NOT content files.',8),loglevel) del_tor += 1 else: if not dry_run: self.tor_delete_recycle(torrent) attr["torrents_deleted_and_contents"]: True - print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) + body += print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) del_tor_cont += 1 else: if not dry_run: self.tor_delete_recycle(torrent) attr["torrents_deleted_and_contents"]: True - print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) + body += print_line(util.insert_space(f'Deleted .torrent AND content files.',8),loglevel) del_tor_cont += 1 + attr = { + "function":"rem_unregistered", + "title":"Removing Unregistered Torrents", + "body": "\n".join(body), + "torrent_name":t_name, + "torrent_status": msg_up, + "torrent_tracker": tags["url"], + "notifiarr_indexer": tags["notifiarr"], + } self.config.send_notifications(attr) if del_tor >=1 or del_tor_cont >=1: if del_tor >= 1: print_line(f"{'Did not delete' if dry_run else 'Deleted'} {del_tor} .torrent{'s' if del_tor > 1 else ''} but not content files.",loglevel) @@ -423,13 +438,15 @@ class Qbt: #Only add cross-seed torrent if original torrent is complete if self.torrentinfo[t_name]['is_complete']: categories.append(category) - print_line(f"{'Not Adding' if dry_run else 'Adding'} to qBittorrent:",loglevel) - print_line(util.insert_space(f'Torrent Name: {t_name}',3),loglevel) - print_line(util.insert_space(f'Category: {category}',7),loglevel) - print_line(util.insert_space(f'Save_Path: {dest}',6),loglevel) + body = [] + body += print_line(f"{'Not Adding' if dry_run else 'Adding'} to qBittorrent:",loglevel) + body += print_line(util.insert_space(f'Torrent Name: {t_name}',3),loglevel) + body += print_line(util.insert_space(f'Category: {category}',7),loglevel) + body += print_line(util.insert_space(f'Save_Path: {dest}',6),loglevel) attr = { "function":"cross_seed", "title":"Adding New Cross-Seed Torrent", + "body": "\n".join(body), "torrent_name":t_name, "torrent_category": category, "torrent_save_path": dest, @@ -451,14 +468,15 @@ class Qbt: t_name = torrent.name if 'cross-seed' not in torrent.tags and self.torrentinfo[t_name]['count'] > 1 and self.torrentinfo[t_name]['first_hash'] != torrent.hash: tagged += 1 + body = print_line(f"{'Not Adding' if dry_run else 'Adding'} 'cross-seed' tag to {t_name}",loglevel) attr = { "function":"tag_cross_seed", "title":"Tagging Cross-Seed Torrent", + "body":body, "torrent_name":t_name, "torrent_add_tag": "cross-seed" } self.config.send_notifications(attr) - print_line(f"{'Not Adding' if dry_run else 'Adding'} 'cross-seed' tag to {t_name}",loglevel) if not dry_run: torrent.add_tags(tags='cross-seed') numcategory = Counter(categories) @@ -485,10 +503,11 @@ class Qbt: if torrent.progress == 1: if torrent.max_ratio < 0 and torrent.max_seeding_time < 0: resumed += 1 - print_line(f"{'Not Resuming' if dry_run else 'Resuming'} [{tags['new_tag']}] - {torrent.name}",loglevel) + body = print_line(f"{'Not Resuming' if dry_run else 'Resuming'} [{tags['new_tag']}] - {torrent.name}",loglevel) attr = { "function":"recheck", "title":"Resuming Torrent", + "body": body, "torrent_name":torrent.name, "torrent_tracker": tags["url"], "notifiarr_indexer": tags["notifiarr"], @@ -505,10 +524,11 @@ class Qbt: or (torrent.max_seeding_time >= 0 and (torrent.seeding_time < (torrent.max_seeding_time * 60)) and torrent.max_ratio < 0) \ or (torrent.max_ratio >= 0 and torrent.max_seeding_time >= 0 and torrent.ratio < torrent.max_ratio and (torrent.seeding_time < (torrent.max_seeding_time * 60))): resumed += 1 - print_line(f"{'Not Resuming' if dry_run else 'Resuming'} [{tags['new_tag']}] - {torrent.name}",loglevel) + body = print_line(f"{'Not Resuming' if dry_run else 'Resuming'} [{tags['new_tag']}] - {torrent.name}",loglevel) attr = { "function":"recheck", "title":"Resuming Torrent", + "body": body, "torrent_name":torrent.name, "torrent_tracker": tags["url"], "notifiarr_indexer": tags["notifiarr"], @@ -518,15 +538,16 @@ class Qbt: #Recheck elif torrent.progress == 0 and self.torrentinfo[torrent.name]['is_complete'] and not torrent.state_enum.is_checking: rechecked += 1 + body = print_line(f"{'Not Rechecking' if dry_run else 'Rechecking'} [{tags['new_tag']}] - {torrent.name}",loglevel) attr = { "function":"recheck", "title":"Rechecking Torrent", + "body": body, "torrent_name":torrent.name, "torrent_tracker": tags["url"], "notifiarr_indexer": tags["notifiarr"], } self.config.send_notifications(attr) - print_line(f"{'Not Rechecking' if dry_run else 'Rechecking'} [{tags['new_tag']}] - {torrent.name}",loglevel) if not dry_run: torrent.recheck() return resumed,rechecked @@ -576,16 +597,19 @@ class Qbt: if orphaned_files: dir_out = os.path.join(remote_path,'orphaned_data') os.makedirs(dir_out,exist_ok=True) - print_line(f"{len(orphaned_files)} Orphaned files found",loglevel) - print_multiline("\n".join(orphaned_files),loglevel) - print_line(f"{'Did not move' if dry_run else 'Moved'} {len(orphaned_files)} Orphaned files to {dir_out.replace(remote_path,root_path)}",loglevel) - orphaned = len(orphaned_files) + body = [] + num_orphaned = len(orphaned_files) + print_line(f"{num_orphaned} Orphaned files found",loglevel) + body += print_multiline("\n".join(orphaned_files),loglevel) + body += print_line(f"{'Did not move' if dry_run else 'Moved'} {num_orphaned} Orphaned files to {dir_out.replace(remote_path,root_path)}",loglevel) + attr = { "function":"rem_orphaned", - "title":f"Removing {len(orphaned_files)} Orphaned Files", + "title":f"Removing {num_orphaned} Orphaned Files", + "body": "\n".join(body), "orphaned_files": list(orphaned_files), "orphaned_directory": dir_out.replace(remote_path,root_path), - "total_orphaned_files": orphaned, + "total_orphaned_files": num_orphaned, } self.config.send_notifications(attr) #Delete empty directories after moving orphan files diff --git a/modules/util.py b/modules/util.py index 7dc3d07..1d2b2ff 100644 --- a/modules/util.py +++ b/modules/util.py @@ -177,6 +177,7 @@ def get_int_list(data, id_type): def print_line(lines, loglevel='INFO'): logger.log(getattr(logging, loglevel.upper()), str(lines)) + return [str(lines)] def print_multiline(lines, loglevel='INFO'): for i, line in enumerate(str(lines).split("\n")): @@ -184,6 +185,7 @@ def print_multiline(lines, loglevel='INFO'): if i == 0: logger.handlers[1].setFormatter(logging.Formatter(" " * 65 + "| %(message)s")) logger.handlers[1].setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)s")) + return [(str(lines))] def print_stacktrace(): print_multiline(traceback.format_exc(), 'CRITICAL') @@ -220,6 +222,7 @@ def separator(text=None, space=True, border=True, loglevel='INFO'): logger.log(getattr(logging, loglevel.upper()), border_text) for handler in logger.handlers: apply_formatter(handler) + return [text] def apply_formatter(handler, border=True): text = f"| %(message)-{screen_width - 2}s |" if border else f"%(message)-{screen_width - 2}s" diff --git a/modules/webhooks.py b/modules/webhooks.py index 4e9a866..4e8f940 100644 --- a/modules/webhooks.py +++ b/modules/webhooks.py @@ -6,7 +6,7 @@ from modules.util import Failed logger = logging.getLogger("qBit Manage") class Webhooks: - def __init__(self, config, system_webhooks, notifiarr=None): + def __init__(self, config, system_webhooks, notifiarr=None, apprise=None): self.config = config self.error_webhooks = system_webhooks["error"] if "error" in system_webhooks else [] self.run_start_webhooks = system_webhooks["run_start"] if "run_start" in system_webhooks else [] @@ -19,6 +19,7 @@ class Webhooks: else: self.function_webhooks = [] self.notifiarr = notifiarr + self.apprise = apprise def _request(self, webhooks, json): if self.config.trace_mode: @@ -28,11 +29,16 @@ class Webhooks: if self.config.trace_mode: logger.debug(f"Webhook: {webhook}") if webhook == "notifiarr": - url, params = self.notifiarr.get_url("notification/qbtManage/") + url, params = self.notifiarr.get_url("notification/qbitManage/") for x in range(6): response = self.config.get(url, json=json, params=params) if response.status_code < 500: break + elif webhook == "apprise": + if self.apprise is None: + raise Failed(f"Webhook attribute set to apprise but apprise attribute is not configured.") + json['urls'] = self.apprise.notify_url + response = self.config.post(f"{self.apprise.api_url}/notify", json=json) else: response = self.config.post(webhook, json=json) try: @@ -56,12 +62,13 @@ class Webhooks: start_type = "" self._request(self.run_start_webhooks, { "function":"run_start", - "title":f"Starting {start_type}Run", + "title": None, + "body":f"Starting {start_type}Run", "start_time": start_time.strftime("%Y-%m-%d %H:%M:%S"), "dry_run": self.config.args['dry_run'] }) - def end_time_hooks(self, start_time, end_time, run_time, stats): + def end_time_hooks(self, start_time, end_time, run_time, stats, body): dry_run = self.config.args['dry_run'] if dry_run: start_type = "Dry-" @@ -70,7 +77,8 @@ class Webhooks: if self.run_end_webhooks: self._request(self.run_end_webhooks, { "function":"run_end", - "title":f"Finished {start_type}Run", + "title": None, + "body": body, "start_time": start_time.strftime("%Y-%m-%d %H:%M:%S"), "end_time": end_time.strftime("%Y-%m-%d %H:%M:%S"), "run_time": run_time, @@ -90,7 +98,8 @@ class Webhooks: def error_hooks(self, text, function_error=None, critical=True): if self.error_webhooks: - json = {"function":"run_error","title":f"{function_error} Error","error": str(text), "critical": critical} + type = "failure" if critical == True else "warning" + json = {"function":"run_error","title":f"{function_error} Error","body": str(text), "critical": critical, "type": type} if function_error: json["function_error"] = function_error self._request(self.error_webhooks, json) diff --git a/qbit_manage.py b/qbit_manage.py index a1ff6b4..be3e5ca 100644 --- a/qbit_manage.py +++ b/qbit_manage.py @@ -227,13 +227,13 @@ def start(): end_time = datetime.now() run_time = str(end_time - start_time).split('.')[0] + body = util.separator(f"Finished {start_type}Run\n{os.linesep.join(stats_summary) if len(stats_summary)>0 else ''}\nRun Time: {run_time}".replace('\n\n', '\n'))[0] if cfg: try: - cfg.Webhooks.end_time_hooks(start_time, end_time, run_time, stats) + cfg.Webhooks.end_time_hooks(start_time, end_time, run_time, stats, body) except Failed as e: util.print_stacktrace() logger.error(f"Webhooks Error: {e}") - util.separator(f"Finished {start_type}Run\n{os.linesep.join(stats_summary) if len(stats_summary)>0 else ''}\nRun Time: {run_time}".replace('\n\n', '\n')) def end(): logger.info("Exiting Qbit_manage") logger.removeHandler(file_handler)