diff --git a/CHANGELOG b/CHANGELOG index d6b7023..265e177 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ -# Bug Fixes -- Fixes #255 -- Fixes #260 -- Fixes #258 +# Requirements Updated +- Updates qbitorrent api to 2023.4.45 +- Updates Schedule to 1.2.0 -**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v3.5.0...v3.5.1 +# Refactoring +- Refactor qbit_manage to split up core functions into separate files + +**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v3.5.1...v3.6.0 diff --git a/README.md b/README.md index 4b0eadb..bf54f82 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/StuffAnThings/qbit_manage?style=plastic)](https://github.com/StuffAnThings/qbit_manage/releases) [![GitHub commits since latest release (by SemVer)](https://img.shields.io/github/commits-since/StuffAnThings/qbit_manage/latest/develop?label=Commits%20in%20Develop&style=plastic)](https://github.com/StuffAnThings/qbit_manage/tree/develop) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/bobokun/qbit_manage?label=docker&sort=semver&style=plastic)](https://hub.docker.com/r/bobokun/qbit_manage) -![Github Workflow Status](https://img.shields.io/github/workflow/status/StuffAnThings/qbit_manage/Docker%20Latest%20Release?style=plastic) +![Github Workflow Status](https://img.shields.io/github/actions/workflow/status/StuffAnThings/qbit_manage/latest.yml?style=plastic) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/StuffAnThings/qbit_manage/master.svg)](https://results.pre-commit.ci/latest/github/StuffAnThings/qbit_manage/master) [![Docker Pulls](https://img.shields.io/docker/pulls/bobokun/qbit_manage?style=plastic)](https://hub.docker.com/r/bobokun/qbit_manage) [![Sponsor or Donate](https://img.shields.io/badge/-Sponsor_or_Donate-blueviolet?style=plastic)](https://github.com/sponsors/bobokun) diff --git a/VERSION b/VERSION index d5c0c99..40c341b 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.1 +3.6.0 diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..78f14cd --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,20 @@ +import os + +# Define an empty version_info tuple +__version_info__ = () + +# Get the path to the project directory +project_dir = os.path.dirname(os.path.abspath(__file__)) + +# Get the path to the VERSION file +version_file_path = os.path.join(project_dir, "..", "VERSION") + +# Read the version from the file +with open(version_file_path) as f: + version_str = f.read().strip() + +# Convert the version string to a tuple of integers +__version_info__ = tuple(map(int, version_str.split("."))) + +# Define the version string using the version_info tuple +__version__ = ".".join(str(i) for i in __version_info__) diff --git a/modules/config.py b/modules/config.py index 5d51c0f..8092b99 100755 --- a/modules/config.py +++ b/modules/config.py @@ -474,240 +474,6 @@ class Config: self.notify(e, "Config") raise Failed(e) - # Get tags from config file based on keyword - def get_tags(self, trackers): - urls = [x.url for x in trackers if x.url.startswith("http")] - tracker = {} - tracker["tag"] = None - tracker["max_ratio"] = None - tracker["min_seeding_time"] = None - tracker["max_seeding_time"] = None - tracker["limit_upload_speed"] = None - tracker["notifiarr"] = None - tracker["url"] = None - tracker_other_tag = self.util.check_for_attribute( - self.data, "tag", parent="tracker", subparent="other", default_is_none=True, var_type="list", save=False - ) - try: - tracker["url"] = util.trunc_val(urls[0], os.sep) - except IndexError as e: - tracker["url"] = None - if not urls: - urls = [] - if not tracker_other_tag: - tracker_other_tag = ["other"] - tracker["url"] = "No http URL found" - else: - logger.debug(f"Tracker Url:{urls}") - logger.debug(e) - if "tracker" in self.data and self.data["tracker"] is not None: - tag_values = self.data["tracker"] - for tag_url, tag_details in tag_values.items(): - for url in urls: - if tag_url in url: - if tracker["url"] is None: - default_tag = tracker_other_tag - else: - try: - tracker["url"] = util.trunc_val(url, os.sep) - default_tag = tracker["url"].split(os.sep)[2].split(":")[0] - except IndexError as e: - logger.debug(f"Tracker Url:{url}") - logger.debug(e) - # Tracker Format 1 deprecated. - if isinstance(tag_details, str): - e = ( - "Config Error: Tracker format invalid. Please see config.yml.sample for correct format and fix " - f"`{tag_details}` in the Tracker section of the config." - ) - self.notify(e, "Config") - raise Failed(e) - # Using new Format - else: - tracker["tag"] = self.util.check_for_attribute( - self.data, "tag", parent="tracker", subparent=tag_url, default=tag_url, var_type="list" - ) - if tracker["tag"] == [tag_url]: - self.data["tracker"][tag_url]["tag"] = [tag_url] - if isinstance(tracker["tag"], str): - tracker["tag"] = [tracker["tag"]] - is_max_ratio_defined = self.data["tracker"].get("max_ratio") - is_max_seeding_time_defined = self.data["tracker"].get("max_seeding_time") - if is_max_ratio_defined or is_max_seeding_time_defined: - tracker["max_ratio"] = self.util.check_for_attribute( - self.data, - "max_ratio", - parent="tracker", - subparent=tag_url, - var_type="float", - min_int=-2, - do_print=False, - default=-1, - save=False, - ) - tracker["max_seeding_time"] = self.util.check_for_attribute( - self.data, - "max_seeding_time", - parent="tracker", - subparent=tag_url, - var_type="int", - min_int=-2, - do_print=False, - default=-1, - save=False, - ) - else: - tracker["max_ratio"] = self.util.check_for_attribute( - self.data, - "max_ratio", - parent="tracker", - subparent=tag_url, - var_type="float", - min_int=-2, - do_print=False, - default_is_none=True, - save=False, - ) - tracker["max_seeding_time"] = self.util.check_for_attribute( - self.data, - "max_seeding_time", - parent="tracker", - subparent=tag_url, - var_type="int", - min_int=-2, - do_print=False, - default_is_none=True, - save=False, - ) - tracker["min_seeding_time"] = self.util.check_for_attribute( - self.data, - "min_seeding_time", - parent="tracker", - subparent=tag_url, - var_type="int", - min_int=0, - do_print=False, - default=0, - save=False, - ) - tracker["limit_upload_speed"] = self.util.check_for_attribute( - self.data, - "limit_upload_speed", - parent="tracker", - subparent=tag_url, - var_type="int", - min_int=-1, - do_print=False, - default=0, - save=False, - ) - tracker["notifiarr"] = self.util.check_for_attribute( - self.data, - "notifiarr", - parent="tracker", - subparent=tag_url, - default_is_none=True, - do_print=False, - save=False, - ) - return tracker - if tracker_other_tag: - tracker["tag"] = tracker_other_tag - tracker["max_ratio"] = self.util.check_for_attribute( - self.data, - "max_ratio", - parent="tracker", - subparent="other", - var_type="float", - min_int=-2, - do_print=False, - default=-1, - save=False, - ) - tracker["min_seeding_time"] = self.util.check_for_attribute( - self.data, - "min_seeding_time", - parent="tracker", - subparent="other", - var_type="int", - min_int=0, - do_print=False, - default=-1, - save=False, - ) - tracker["max_seeding_time"] = self.util.check_for_attribute( - self.data, - "max_seeding_time", - parent="tracker", - subparent="other", - var_type="int", - min_int=-2, - do_print=False, - default=-1, - save=False, - ) - tracker["limit_upload_speed"] = self.util.check_for_attribute( - self.data, - "limit_upload_speed", - parent="tracker", - subparent="other", - var_type="int", - min_int=-1, - do_print=False, - default=0, - save=False, - ) - tracker["notifiarr"] = self.util.check_for_attribute( - self.data, - "notifiarr", - parent="tracker", - subparent="other", - default_is_none=True, - do_print=False, - save=False, - ) - return tracker - if tracker["url"]: - logger.trace(f"tracker url: {tracker['url']}") - if tracker_other_tag: - default_tag = tracker_other_tag - else: - default_tag = tracker["url"].split(os.sep)[2].split(":")[0] - tracker["tag"] = self.util.check_for_attribute( - self.data, "tag", parent="tracker", subparent=default_tag, default=default_tag, var_type="list" - ) - if isinstance(tracker["tag"], str): - tracker["tag"] = [tracker["tag"]] - try: - self.data["tracker"][default_tag]["tag"] = [default_tag] - except Exception: - self.data["tracker"][default_tag] = {"tag": [default_tag]} - e = f'No tags matched for {tracker["url"]}. Please check your config.yml file. Setting tag to {default_tag}' - self.notify(e, "Tag", False) - logger.warning(e) - return tracker - - # Get category from config file based on path provided - def get_category(self, path): - category = "" - path = os.path.join(path, "") - if "cat" in self.data and self.data["cat"] is not None: - cat_path = self.data["cat"] - for cat, save_path in cat_path.items(): - if os.path.join(save_path, "") == path: - category = cat - break - - if not category: - default_cat = path.split(os.sep)[-2] - category = str(default_cat) - self.util.check_for_attribute(self.data, default_cat, parent="cat", default=path) - self.data["cat"][str(default_cat)] = path - e = f"No categories matched for the save path {path}. Check your config.yml file. - Setting category to {default_cat}" - self.notify(e, "Category", False) - logger.warning(e) - return category - # Empty old files from recycle bin or orphaned def cleanup_dirs(self, location): num_del = 0 diff --git a/modules/core/__init__.py b/modules/core/__init__.py new file mode 100644 index 0000000..a285905 --- /dev/null +++ b/modules/core/__init__.py @@ -0,0 +1,3 @@ +""" + modules.core contains all the core functions of qbit_manage such as updating categories/tags etc.. +""" diff --git a/modules/core/category.py b/modules/core/category.py new file mode 100644 index 0000000..6cfcbec --- /dev/null +++ b/modules/core/category.py @@ -0,0 +1,76 @@ +from qbittorrentapi import Conflict409Error + +from modules import util + +logger = util.logger + + +class Category: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats = 0 + + self.category() + + def category(self): + """Update category for torrents that don't have any category defined and returns total number categories updated""" + logger.separator("Updating Categories", space=False, border=False) + torrent_list = self.qbt.get_torrents({"category": "", "status_filter": "completed"}) + for torrent in torrent_list: + new_cat = self.qbt.get_category(torrent.save_path) + self.update_cat(torrent, new_cat, False) + + # Change categories + if self.config.cat_change: + for old_cat in self.config.cat_change: + torrent_list = self.qbt.get_torrents({"category": old_cat, "status_filter": "completed"}) + for torrent in torrent_list: + new_cat = self.config.cat_change[old_cat] + self.update_cat(torrent, new_cat, True) + + if self.stats >= 1: + logger.print_line( + f"{'Did not update' if self.config.dry_run else 'Updated'} {self.stats} new categories.", self.config.loglevel + ) + else: + logger.print_line("No new torrents to categorize.", self.config.loglevel) + + def update_cat(self, torrent, new_cat, cat_change): + """Update category based on the torrent information""" + tracker = self.qbt.get_tags(torrent.trackers) + old_cat = torrent.category + if not self.config.dry_run: + try: + torrent.set_category(category=new_cat) + if torrent.auto_tmm is False and self.config.settings["force_auto_tmm"]: + torrent.set_auto_management(True) + except Conflict409Error: + ex = logger.print_line( + f'Existing category "{new_cat}" not found for save path {torrent.save_path}, category will be created.', + self.config.loglevel, + ) + self.config.notify(ex, "Update Category", False) + self.client.torrent_categories.create_category(name=new_cat, save_path=torrent.save_path) + torrent.set_category(category=new_cat) + body = [] + body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) + if cat_change: + body += logger.print_line(logger.insert_space(f"Old Category: {old_cat}", 3), self.config.loglevel) + title = "Moving Categories" + else: + title = "Updating Categories" + body += logger.print_line(logger.insert_space(f"New Category: {new_cat}", 3), self.config.loglevel) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + attr = { + "function": "cat_update", + "title": title, + "body": "\n".join(body), + "torrent_name": torrent.name, + "torrent_category": new_cat, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + self.stats += 1 diff --git a/modules/core/cross_seed.py b/modules/core/cross_seed.py new file mode 100644 index 0000000..3c5c255 --- /dev/null +++ b/modules/core/cross_seed.py @@ -0,0 +1,124 @@ +import os +from collections import Counter + +from modules import util + +logger = util.logger + + +class CrossSeed: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats_added = 0 + self.stats_tagged = 0 + + self.cross_seed() + + def cross_seed(self): + """Move torrents from cross seed directory to correct save directory.""" + logger.separator("Checking for Cross-Seed Torrents", space=False, border=False) + # List of categories for all torrents moved + categories = [] + + # Only get torrent files + cs_files = [f for f in os.listdir(self.config.cross_seed_dir) if f.endswith("torrent")] + dir_cs = self.config.cross_seed_dir + dir_cs_out = os.path.join(dir_cs, "qbit_manage_added") + os.makedirs(dir_cs_out, exist_ok=True) + for file in cs_files: + tr_name = file.split("]", 2)[2].split(".torrent")[0] + t_tracker = file.split("]", 2)[1][1:] + # Substring Key match in dictionary (used because t_name might not match exactly with self.qbt.torrentinfo key) + # Returned the dictionary of filtered item + torrentdict_file = dict(filter(lambda item: tr_name in item[0], self.qbt.torrentinfo.items())) + if torrentdict_file: + # Get the exact torrent match name from self.qbt.torrentinfo + t_name = next(iter(torrentdict_file)) + dest = os.path.join(self.qbt.torrentinfo[t_name]["save_path"], "") + src = os.path.join(dir_cs, file) + dir_cs_out = os.path.join(dir_cs, "qbit_manage_added", file) + category = self.qbt.global_max_ratioget_category(dest) + # Only add cross-seed torrent if original torrent is complete + if self.qbt.torrentinfo[t_name]["is_complete"]: + categories.append(category) + body = [] + body += logger.print_line( + f"{'Not Adding' if self.config.dry_run else 'Adding'} to qBittorrent:", self.config.loglevel + ) + body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) + body += logger.print_line(logger.insert_space(f"Category: {category}", 7), self.config.loglevel) + body += logger.print_line(logger.insert_space(f"Save_Path: {dest}", 6), self.config.loglevel) + body += logger.print_line(logger.insert_space(f"Tracker: {t_tracker}", 8), self.config.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, + "torrent_tag": "cross-seed", + "torrent_tracker": t_tracker, + } + self.config.send_notifications(attr) + self.stats_added += 1 + if not self.config.dry_run: + self.client.torrents.add( + torrent_files=src, save_path=dest, category=category, tags="cross-seed", is_paused=True + ) + util.move_files(src, dir_cs_out) + else: + logger.print_line(f"Found {t_name} in {dir_cs} but original torrent is not complete.", self.config.loglevel) + logger.print_line("Not adding to qBittorrent", self.config.loglevel) + else: + error = f"{t_name} not found in torrents. Cross-seed Torrent not added to qBittorrent." + if self.config.dry_run: + logger.print_line(error, self.config.loglevel) + else: + logger.print_line(error, "WARNING") + self.config.notify(error, "cross-seed", False) + # Tag missing cross-seed torrents tags + for torrent in self.qbt.torrent_list: + t_name = torrent.name + t_cat = torrent.category + if ( + "cross-seed" not in torrent.tags + and self.qbt.torrentinfo[t_name]["count"] > 1 + and self.qbt.torrentinfo[t_name]["first_hash"] != torrent.hash + ): + tracker = self.qbt.get_tags(torrent.trackers) + self.stats_tagged += 1 + body = logger.print_line( + f"{'Not Adding' if self.config.dry_run else 'Adding'} 'cross-seed' tag to {t_name}", self.config.loglevel + ) + attr = { + "function": "tag_cross_seed", + "title": "Tagging Cross-Seed Torrent", + "body": body, + "torrent_name": t_name, + "torrent_category": t_cat, + "torrent_tag": "cross-seed", + "torrent_tracker": tracker, + } + self.config.send_notifications(attr) + if not self.config.dry_run: + torrent.add_tags(tags="cross-seed") + + numcategory = Counter(categories) + for cat in numcategory: + if numcategory[cat] > 0: + logger.print_line( + f"{numcategory[cat]} {cat} cross-seed .torrents {'not added' if self.config.dry_run else 'added'}.", + self.config.loglevel, + ) + if self.stats_added > 0: + logger.print_line( + f"Total {self.stats_added} cross-seed .torrents {'not added' if self.config.dry_run else 'added'}.", + self.config.loglevel, + ) + if self.stats_tagged > 0: + logger.print_line( + f"Total {self.stats_tagged} cross-seed .torrents {'not added' if self.config.dry_run else 'added'}.", + self.config.loglevel, + ) diff --git a/modules/core/recheck.py b/modules/core/recheck.py new file mode 100644 index 0000000..1fc2e5d --- /dev/null +++ b/modules/core/recheck.py @@ -0,0 +1,115 @@ +from datetime import timedelta + +from modules import util + +logger = util.logger + + +class ReCheck: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats_resumed = 0 + self.stats_rechecked = 0 + + self.recheck() + + def recheck(self): + """Function used to recheck paused torrents sorted by size and resume torrents that are completed""" + if self.config.commands["recheck"]: + logger.separator("Rechecking Paused Torrents", space=False, border=False) + # sort by size and paused + torrent_list = self.qbt.get_torrents({"status_filter": "paused", "sort": "size"}) + if torrent_list: + for torrent in torrent_list: + tracker = self.qbt.get_tags(torrent.trackers) + # Resume torrent if completed + if torrent.progress == 1: + if torrent.max_ratio < 0 and torrent.max_seeding_time < 0: + self.stats_resumed += 1 + body = logger.print_line( + f"{'Not Resuming' if self.config.dry_run else 'Resuming'} [{tracker['tag']}] - {torrent.name}", + self.config.loglevel, + ) + attr = { + "function": "recheck", + "title": "Resuming Torrent", + "body": body, + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + if not self.config.dry_run: + torrent.resume() + else: + # Check to see if torrent meets AutoTorrentManagement criteria + logger.debug("DEBUG: Torrent to see if torrent meets AutoTorrentManagement Criteria") + logger.debug(logger.insert_space(f"- Torrent Name: {torrent.name}", 2)) + logger.debug( + logger.insert_space(f"-- Ratio vs Max Ratio: {torrent.ratio:.2f} < {torrent.max_ratio:.2f}", 4) + ) + logger.debug( + logger.insert_space( + f"-- Seeding Time vs Max Seed Time: {timedelta(seconds=torrent.seeding_time)} < " + f"{timedelta(minutes=torrent.max_seeding_time)}", + 4, + ) + ) + if ( + (torrent.max_ratio >= 0 and torrent.ratio < torrent.max_ratio and torrent.max_seeding_time < 0) + 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)) + ) + ): + self.stats_resumed += 1 + body = logger.print_line( + f"{'Not Resuming' if self.config.dry_run else 'Resuming'} [{tracker['tag']}] - " + f"{torrent.name}", + self.config.loglevel, + ) + attr = { + "function": "recheck", + "title": "Resuming Torrent", + "body": body, + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + if not self.config.dry_run: + torrent.resume() + # Recheck + elif ( + torrent.progress == 0 + and self.qbt.torrentinfo[torrent.name]["is_complete"] + and not torrent.state_enum.is_checking + ): + self.stats_rechecked += 1 + body = logger.print_line( + f"{'Not Rechecking' if self.config.dry_run else 'Rechecking'} [{tracker['tag']}] - {torrent.name}", + self.config.loglevel, + ) + attr = { + "function": "recheck", + "title": "Rechecking Torrent", + "body": body, + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + if not self.config.dry_run: + torrent.recheck() diff --git a/modules/core/remove_orphaned.py b/modules/core/remove_orphaned.py new file mode 100644 index 0000000..2d14f6c --- /dev/null +++ b/modules/core/remove_orphaned.py @@ -0,0 +1,102 @@ +import os +from fnmatch import fnmatch + +from modules import util + +logger = util.logger + + +class RemoveOrphaned: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats = 0 + + self.remote_dir = qbit_manager.config.remote_dir + self.root_dir = qbit_manager.config.root_dir + self.orphaned_dir = qbit_manager.config.orphaned_dir + + self.rem_orphaned() + + def rem_orphaned(self): + """Remove orphaned files from remote directory""" + self.stats = 0 + logger.separator("Checking for Orphaned Files", space=False, border=False) + torrent_files = [] + root_files = [] + orphaned_files = [] + excluded_orphan_files = [] + orphaned_parent_path = set() + + if self.remote_dir != self.root_dir: + root_files = [ + os.path.join(path.replace(self.remote_dir, self.root_dir), name) + for path, subdirs, files in os.walk(self.remote_dir) + for name in files + if self.orphaned_dir.replace(self.remote_dir, self.root_dir) not in path + ] + else: + root_files = [ + os.path.join(path, name) + for path, subdirs, files in os.walk(self.root_dir) + for name in files + if self.orphaned_dir.replace(self.root_dir, self.remote_dir) not in path + ] + + # Get an updated list of torrents + torrent_list = self.qbt.get_torrents({"sort": "added_on"}) + for torrent in torrent_list: + for file in torrent.files: + fullpath = os.path.join(torrent.save_path, file.name) + # Replace fullpath with \\ if qbm is running in docker (linux) but qbt is on windows + fullpath = fullpath.replace(r"/", "\\") if ":\\" in fullpath else fullpath + torrent_files.append(fullpath) + + orphaned_files = set(root_files) - set(torrent_files) + orphaned_files = sorted(orphaned_files) + + if self.config.orphaned["exclude_patterns"]: + exclude_patterns = self.config.orphaned["exclude_patterns"] + excluded_orphan_files = [ + file + for file in orphaned_files + for exclude_pattern in exclude_patterns + if fnmatch(file, exclude_pattern.replace(self.remote_dir, self.root_dir)) + ] + + orphaned_files = set(orphaned_files) - set(excluded_orphan_files) + + if orphaned_files: + os.makedirs(self.orphaned_dir, exist_ok=True) + body = [] + num_orphaned = len(orphaned_files) + logger.print_line(f"{num_orphaned} Orphaned files found", self.config.loglevel) + body += logger.print_line("\n".join(orphaned_files), self.config.loglevel) + body += logger.print_line( + f"{'Did not move' if self.config.dry_run else 'Moved'} {num_orphaned} Orphaned files " + f"to {self.orphaned_dir.replace(self.remote_dir,self.root_dir)}", + self.config.loglevel, + ) + + attr = { + "function": "rem_orphaned", + "title": f"Removing {num_orphaned} Orphaned Files", + "body": "\n".join(body), + "orphaned_files": list(orphaned_files), + "orphaned_directory": self.orphaned_dir.replace(self.remote_dir, self.root_dir), + "total_orphaned_files": num_orphaned, + } + self.config.send_notifications(attr) + # Delete empty directories after moving orphan files + logger.info("Cleaning up any empty directories...") + if not self.config.dry_run: + for file in orphaned_files: + src = file.replace(self.root_dir, self.remote_dir) + dest = os.path.join(self.orphaned_dir, file.replace(self.root_dir, "")) + util.move_files(src, dest, True) + orphaned_parent_path.add(os.path.dirname(file).replace(self.root_dir, self.remote_dir)) + for parent_path in orphaned_parent_path: + util.remove_empty_directories(parent_path, "**/*") + else: + logger.print_line("No Orphaned Files found.", self.config.loglevel) diff --git a/modules/core/remove_unregistered.py b/modules/core/remove_unregistered.py new file mode 100644 index 0000000..40d283d --- /dev/null +++ b/modules/core/remove_unregistered.py @@ -0,0 +1,216 @@ +from qbittorrentapi import NotFound404Error +from qbittorrentapi import TrackerStatus + +from modules import util +from modules.util import list_in_text +from modules.util import TorrentMessages + +logger = util.logger + + +class RemoveUnregistered: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats_deleted = 0 + self.stats_deleted_contents = 0 + self.stats_tagged = 0 + self.stats_untagged = 0 + self.tor_error_summary = "" + self.tag_error = self.config.tracker_error_tag + self.cfg_rem_unregistered = self.config.commands["rem_unregistered"] + self.cfg_tag_error = self.config.commands["tag_tracker_error"] + + tag_error_msg = "Tagging Torrents with Tracker Errors" if self.cfg_tag_error else "" + rem_unregistered_msg = "Removing Unregistered Torrents" if self.cfg_rem_unregistered else "" + + if tag_error_msg and rem_unregistered_msg: + message = f"{tag_error_msg} and {rem_unregistered_msg}" + elif tag_error_msg: + message = tag_error_msg + elif rem_unregistered_msg: + message = rem_unregistered_msg + + if message: + logger.separator(message, space=False, border=False) + + self.rem_unregistered() + + def remove_previous_errors(self): + """Removes any previous torrents that were tagged as an error but are now working.""" + for torrent in self.qbt.torrentvalid: + check_tags = util.get_list(torrent.tags) + # Remove any error torrents Tags that are no longer unreachable. + if self.tag_error in check_tags: + tracker = self.qbt.get_tags(torrent.trackers) + self.stats_untagged += 1 + body = [] + body += logger.print_line( + f"Previous Tagged {self.tag_error} torrent currently has a working tracker.", self.config.loglevel + ) + body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) + body += logger.print_line(logger.insert_space(f"Removed Tag: {self.tag_error}", 4), self.config.loglevel) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + if not self.config.dry_run: + torrent.remove_tags(tags=self.tag_error) + attr = { + "function": "untag_tracker_error", + "title": "Untagging Tracker Error Torrent", + "body": "\n".join(body), + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tag": self.tag_error, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + + def check_for_unregistered_torrents_using_bhd_api(self, tracker, msg_up, torrent_hash): + """ + Checks if a torrent is unregistered using the BHD API if the tracker is BHD. + """ + if ( + "tracker.beyond-hd.me" in tracker["url"] + and self.config.beyond_hd is not None + and not list_in_text(msg_up, TorrentMessages.IGNORE_MSGS) + ): + json = {"info_hash": torrent_hash} + response = self.config.beyond_hd.search(json) + if response["total_results"] == 0: + return True + return False + + def process_torrent_issues(self): + for torrent in self.qbt.torrentissue: + self.t_name = torrent.name + self.t_cat = self.qbt.torrentinfo[self.t_name]["Category"] + self.t_msg = self.qbt.torrentinfo[self.t_name]["msg"] + self.t_status = self.qbt.torrentinfo[self.t_name]["status"] + check_tags = util.get_list(torrent.tags) + try: + for trk in torrent.trackers: + if trk.url.startswith("http"): + tracker = self.qbt.get_tags([trk]) + msg_up = trk.msg.upper() + msg = trk.msg + if TrackerStatus(trk.status) == TrackerStatus.NOT_WORKING: + # Tag any error torrents + if self.cfg_tag_error and self.tag_error not in check_tags: + self.tag_tracker_error(msg, tracker, torrent) + # Check for unregistered torrents + if self.cfg_rem_unregistered: + if list_in_text(msg_up, TorrentMessages.UNREGISTERED_MSGS) and not list_in_text( + msg_up, TorrentMessages.IGNORE_MSGS + ): + self.del_unregistered(msg, tracker, torrent) + break + else: + if self.check_for_unregistered_torrents_using_bhd_api(tracker, msg_up, torrent.hash): + self.del_unregistered(msg, tracker, torrent) + break + + except NotFound404Error: + continue + except Exception as ex: + logger.stacktrace() + self.config.notify(ex, "Remove Unregistered Torrents", False) + logger.error(f"Remove Unregistered Torrents Error: {ex}") + + def rem_unregistered(self): + """Remove torrents with unregistered trackers.""" + self.remove_previous_errors() + self.process_torrent_issues() + if self.cfg_rem_unregistered: + if self.stats_deleted >= 1 or self.stats_deleted_contents >= 1: + if self.stats_deleted >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} {self.stats_deleted} " + f".torrent{'s' if self.stats_deleted > 1 else ''} but not content files.", + self.config.loglevel, + ) + if self.stats_deleted_contents >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} {self.stats_deleted_contents} " + f".torrent{'s' if self.stats_deleted_contents > 1 else ''} AND content files.", + self.config.loglevel, + ) + else: + logger.print_line("No unregistered torrents found.", self.config.loglevel) + if self.stats_untagged >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} {self.tag_error} tags for {self.stats_untagged} " + f".torrent{'s.' if self.stats_untagged > 1 else '.'}", + self.config.loglevel, + ) + if self.stats_tagged >= 1: + logger.separator( + f"{self.stats_tagged} Torrents with tracker errors found", + space=False, + border=False, + loglevel=self.config.loglevel, + ) + logger.print_line(self.tor_error_summary.rstrip(), self.config.loglevel) + + def tag_tracker_error(self, msg, tracker, torrent): + """Tags any trackers with errors""" + tor_error = "" + tor_error += logger.insert_space(f"Torrent Name: {self.t_name}", 3) + "\n" + tor_error += logger.insert_space(f"Status: {msg}", 9) + "\n" + tor_error += logger.insert_space(f'Tracker: {tracker["url"]}', 8) + "\n" + tor_error += logger.insert_space(f"Added Tag: {self.tag_error}", 6) + "\n" + self.tor_error_summary += tor_error + self.stats_tagged += 1 + attr = { + "function": "tag_tracker_error", + "title": "Tag Tracker Error Torrents", + "body": tor_error, + "torrent_name": self.t_name, + "torrent_category": self.t_cat, + "torrent_tag": self.tag_error, + "torrent_status": msg, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + self.config.send_notifications(attr) + if not self.config.dry_run: + torrent.add_tags(tags=self.tag_error) + + def del_unregistered(self, msg, tracker, torrent): + """Deletes unregistered torrents""" + body = [] + body += logger.print_line(logger.insert_space(f"Torrent Name: {self.t_name}", 3), self.config.loglevel) + body += logger.print_line(logger.insert_space(f"Status: {msg}", 9), self.config.loglevel) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + attr = { + "function": "rem_unregistered", + "title": "Removing Unregistered Torrents", + "torrent_name": self.t_name, + "torrent_category": self.t_cat, + "torrent_status": msg, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + if self.qbt.torrentinfo[self.t_name]["count"] > 1: + # Checks if any of the original torrents are working + if "" in self.t_msg or 2 in self.t_status: + attr["torrents_deleted_and_contents"] = False + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line(logger.insert_space("Deleted .torrent but NOT content files.", 8), self.config.loglevel) + self.stats_deleted += 1 + else: + attr["torrents_deleted_and_contents"] = True + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line(logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel) + self.stats_deleted_contents += 1 + else: + attr["torrents_deleted_and_contents"] = True + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line(logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel) + self.stats_deleted_contents += 1 + attr["body"] = "\n".join(body) + self.config.send_notifications(attr) + self.qbt.torrentinfo[self.t_name]["count"] -= 1 diff --git a/modules/core/tag_nohardlinks.py b/modules/core/tag_nohardlinks.py new file mode 100644 index 0000000..dbd3c8d --- /dev/null +++ b/modules/core/tag_nohardlinks.py @@ -0,0 +1,350 @@ +import os + +from modules import util + +logger = util.logger + + +class TagNoHardLinks: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats_tagged = 0 # counter for the number of torrents that has no hardlinks + self.stats_untagged = 0 # counter for number of torrents that previously had no hardlinks but now have hardlinks + self.stats_deleted = 0 # counter for the number of torrents that has no hardlinks and \ + # meets the criteria for ratio limit/seed limit for deletion + self.stats_deleted_contents = 0 # counter for the number of torrents that has no hardlinks and \ + # meets the criteria for ratio limit/seed limit for deletion including contents + + self.tdel_dict = {} # dictionary to track the torrent names and content path that meet the deletion criteria + self.root_dir = qbit_manager.config.root_dir + self.remote_dir = qbit_manager.config.remote_dir + self.nohardlinks = qbit_manager.config.nohardlinks + self.nohardlinks_tag = qbit_manager.config.nohardlinks_tag + + self.tag_nohardlinks() + + def add_tag_no_hl(self, torrent, tracker, category, max_ratio, max_seeding_time, add_tag=True): + """Add tag nohardlinks_tag to torrents with no hardlinks""" + body = [] + body.append(logger.insert_space(f"Torrent Name: {torrent.name}", 3)) + if add_tag: + body.append(logger.insert_space(f"Added Tag: {self.nohardlinks_tag}", 6)) + title = "Tagging Torrents with No Hardlinks" + else: + title = "Changing Share Ratio of Torrents with No Hardlinks" + body.append(logger.insert_space(f'Tracker: {tracker["url"]}', 8)) + body_tags_and_limits = self.qbt.set_tags_and_limits( + torrent, + max_ratio, + max_seeding_time, + self.nohardlinks[category]["limit_upload_speed"], + tags=self.nohardlinks_tag, + do_print=False, + ) + if body_tags_and_limits or add_tag: + self.stats_tagged += 1 + # Resume torrent if it was paused now that the share limit has changed + if torrent.state_enum.is_complete and self.nohardlinks[category]["resume_torrent_after_untagging_noHL"]: + if not self.config.dry_run: + torrent.resume() + body.extend(body_tags_and_limits) + for rcd in body: + logger.print_line(rcd, self.config.loglevel) + attr = { + "function": "tag_nohardlinks", + "title": title, + "body": "\n".join(body), + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tag": self.nohardlinks_tag, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + "torrent_max_ratio": max_ratio, + "torrent_max_seeding_time": max_seeding_time, + "torrent_limit_upload_speed": self.nohardlinks[category]["limit_upload_speed"], + } + self.config.send_notifications(attr) + + def cleanup_tagged_torrents_with_no_hardlinks(self, category): + """Delete any tagged torrents that meet noHL criteria""" + # loop through torrent list again for cleanup purposes + if self.nohardlinks[category]["cleanup"]: + torrent_list = self.qbt.get_torrents({"category": category, "status_filter": "completed"}) + for torrent in torrent_list: + t_name = torrent.name + t_hash = torrent.hash + if t_hash in self.tdel_dict and self.nohardlinks_tag in torrent.tags: + t_count = self.qbt.torrentinfo[t_name]["count"] + t_msg = self.qbt.torrentinfo[t_name]["msg"] + t_status = self.qbt.torrentinfo[t_name]["status"] + # Double check that the content path is the same before we delete anything + if torrent["content_path"].replace(self.root_dir, self.remote_dir) == self.tdel_dict[t_hash]["content_path"]: + tracker = self.qbt.get_tags(torrent.trackers) + body = [] + body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + body += logger.print_line(self.tdel_dict[t_hash]["body"], self.config.loglevel) + body += logger.print_line( + logger.insert_space("Cleanup: True [No hardlinks found and meets Share Limits.]", 8), + self.config.loglevel, + ) + attr = { + "function": "cleanup_tag_nohardlinks", + "title": "Removing NoHL Torrents and meets Share Limits", + "torrent_name": t_name, + "torrent_category": torrent.category, + "cleanup": "True", + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + } + if os.path.exists(torrent["content_path"].replace(self.root_dir, self.remote_dir)): + # Checks if any of the original torrents are working + if t_count > 1 and ("" in t_msg or 2 in t_status): + self.stats_deleted += 1 + attr["torrents_deleted_and_contents"] = False + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line( + logger.insert_space("Deleted .torrent but NOT content files.", 8), + self.config.loglevel, + ) + else: + self.stats_deleted_contents += 1 + attr["torrents_deleted_and_contents"] = True + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line( + logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel + ) + else: + self.stats_deleted += 1 + attr["torrents_deleted_and_contents"] = False + if not self.config.dry_run: + self.qbt.tor_delete_recycle(torrent, attr) + body += logger.print_line( + logger.insert_space("Deleted .torrent but NOT content files.", 8), self.config.loglevel + ) + attr["body"] = "\n".join(body) + self.config.send_notifications(attr) + self.qbt.torrentinfo[t_name]["count"] -= 1 + + def check_previous_nohardlinks_tagged_torrents(self, has_nohardlinks, torrent, tracker, category): + """ + Checks for any previous torrents that were tagged with the nohardlinks tag and have since had hardlinks added. + If any are found, the nohardlinks tag is removed from the torrent and the tracker or global share limits are restored. + If the torrent is complete and the option to resume after untagging is enabled, the torrent is resumed. + """ + if not (has_nohardlinks) and (self.nohardlinks_tag in torrent.tags): + self.stats_untagged += 1 + body = [] + body += logger.print_line( + f"Previous Tagged {self.nohardlinks_tag} " f"Torrent Name: {torrent.name} has hardlinks found now.", + self.config.loglevel, + ) + body += logger.print_line(logger.insert_space(f"Removed Tag: {self.nohardlinks_tag}", 6), self.config.loglevel) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + body += logger.print_line( + f"{'Not Reverting' if self.config.dry_run else 'Reverting'} to tracker or Global share limits.", + self.config.loglevel, + ) + restore_max_ratio = tracker["max_ratio"] + restore_max_seeding_time = tracker["max_seeding_time"] + restore_limit_upload_speed = tracker["limit_upload_speed"] + if restore_max_ratio is None: + restore_max_ratio = -2 + if restore_max_seeding_time is None: + restore_max_seeding_time = -2 + if restore_limit_upload_speed is None: + restore_limit_upload_speed = -1 + if not self.config.dry_run: + torrent.remove_tags(tags=self.nohardlinks_tag) + body.extend( + self.qbt.set_tags_and_limits( + torrent, restore_max_ratio, restore_max_seeding_time, restore_limit_upload_speed, restore=True + ) + ) + if torrent.state_enum.is_complete and self.nohardlinks[category]["resume_torrent_after_untagging_noHL"]: + torrent.resume() + attr = { + "function": "untag_nohardlinks", + "title": "Untagging Previous Torrents that now have hardlinks", + "body": "\n".join(body), + "torrent_name": torrent.name, + "torrent_category": torrent.category, + "torrent_tag": self.nohardlinks_tag, + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + "torrent_max_ratio": restore_max_ratio, + "torrent_max_seeding_time": restore_max_seeding_time, + "torrent_limit_upload_speed": restore_limit_upload_speed, + } + self.config.send_notifications(attr) + + def tag_nohardlinks(self): + """Tag torrents with no hardlinks""" + logger.separator("Tagging Torrents with No Hardlinks", space=False, border=False) + nohardlinks = self.nohardlinks + for category in nohardlinks: + torrent_list = self.qbt.get_torrents({"category": category, "status_filter": "completed"}) + if len(torrent_list) == 0: + ex = ( + "No torrents found in the category (" + + category + + ") defined under nohardlinks attribute in the config. " + + "Please check if this matches with any category in qbittorrent and has 1 or more torrents." + ) + logger.warning(ex) + continue + for torrent in torrent_list: + tracker = self.qbt.get_tags(torrent.trackers) + has_nohardlinks = util.nohardlink( + torrent["content_path"].replace(self.root_dir, self.remote_dir), self.config.notify + ) + if any(tag in torrent.tags for tag in nohardlinks[category]["exclude_tags"]): + # Skip to the next torrent if we find any torrents that are in the exclude tag + continue + else: + # Checks for any hardlinks and not already tagged + # Cleans up previously tagged nohardlinks_tag torrents that no longer have hardlinks + if has_nohardlinks: + tracker = self.qbt.get_tags(torrent.trackers) + # Determine min_seeding_time. + # If only tracker setting is set, use tracker's min_seeding_time + # If only nohardlinks category setting is set, use nohardlinks category's min_seeding_time + # If both tracker and nohardlinks category setting is set, use the larger of the two + # If neither set, use 0 (no limit) + min_seeding_time = 0 + logger.trace(f'tracker["min_seeding_time"] is {tracker["min_seeding_time"]}') + logger.trace(f'nohardlinks[category]["min_seeding_time"] is {nohardlinks[category]["min_seeding_time"]}') + if tracker["min_seeding_time"] is not None and nohardlinks[category]["min_seeding_time"] is not None: + if tracker["min_seeding_time"] >= nohardlinks[category]["min_seeding_time"]: + min_seeding_time = tracker["min_seeding_time"] + logger.trace(f'Using tracker["min_seeding_time"] {min_seeding_time}') + else: + min_seeding_time = nohardlinks[category]["min_seeding_time"] + logger.trace(f'Using nohardlinks[category]["min_seeding_time"] {min_seeding_time}') + elif nohardlinks[category]["min_seeding_time"]: + min_seeding_time = nohardlinks[category]["min_seeding_time"] + logger.trace(f'Using nohardlinks[category]["min_seeding_time"] {min_seeding_time}') + elif tracker["min_seeding_time"]: + min_seeding_time = tracker["min_seeding_time"] + logger.trace(f'Using tracker["min_seeding_time"] {min_seeding_time}') + else: + logger.trace(f"Using default min_seeding_time {min_seeding_time}") + # Determine max_ratio. + # If only tracker setting is set, use tracker's max_ratio + # If only nohardlinks category setting is set, use nohardlinks category's max_ratio + # If both tracker and nohardlinks category setting is set, use the larger of the two + # If neither set, use -1 (no limit) + max_ratio = -1 + logger.trace(f'tracker["max_ratio"] is {tracker["max_ratio"]}') + logger.trace(f'nohardlinks[category]["max_ratio"] is {nohardlinks[category]["max_ratio"]}') + if tracker["max_ratio"] is not None and nohardlinks[category]["max_ratio"] is not None: + if tracker["max_ratio"] >= nohardlinks[category]["max_ratio"]: + max_ratio = tracker["max_ratio"] + logger.trace(f'Using (tracker["max_ratio"]) {max_ratio}') + else: + max_ratio = nohardlinks[category]["max_ratio"] + logger.trace(f'Using (nohardlinks[category]["max_ratio"]) {max_ratio}') + elif nohardlinks[category]["max_ratio"]: + max_ratio = nohardlinks[category]["max_ratio"] + logger.trace(f'Using (nohardlinks[category]["max_ratio"]) {max_ratio}') + elif tracker["max_ratio"]: + max_ratio = tracker["max_ratio"] + logger.trace(f'Using (tracker["max_ratio"]) {max_ratio}') + else: + logger.trace(f"Using default (max_ratio) {max_ratio}") + # Determine max_seeding_time. + # If only tracker setting is set, use tracker's max_seeding_time + # If only nohardlinks category setting is set, use nohardlinks category's max_seeding_time + # If both tracker and nohardlinks category setting is set, use the larger of the two + # If neither set, use -1 (no limit) + max_seeding_time = -1 + logger.trace(f'tracker["max_seeding_time"] is {tracker["max_seeding_time"]}') + logger.trace(f'nohardlinks[category]["max_seeding_time"] is {nohardlinks[category]["max_seeding_time"]}') + if tracker["max_seeding_time"] is not None and nohardlinks[category]["max_seeding_time"] is not None: + if tracker["max_seeding_time"] >= nohardlinks[category]["max_seeding_time"]: + max_seeding_time = tracker["max_seeding_time"] + logger.trace(f'Using (tracker["max_seeding_time"]) {max_seeding_time}') + else: + max_seeding_time = nohardlinks[category]["max_seeding_time"] + logger.trace(f'Using (nohardlinks[category]["max_seeding_time"]) {max_seeding_time}') + elif nohardlinks[category]["max_seeding_time"]: + max_seeding_time = nohardlinks[category]["max_seeding_time"] + logger.trace(f'Using (nohardlinks[category]["max_seeding_time"]) {max_seeding_time}') + elif tracker["max_seeding_time"]: + max_seeding_time = tracker["max_seeding_time"] + logger.trace(f'Using (tracker["max_seeding_time"]) {max_seeding_time}') + else: + logger.trace(f"Using default (max_seeding_time) {max_seeding_time}") + # Will only tag new torrents that don't have nohardlinks_tag tag + if self.nohardlinks_tag not in torrent.tags: + self.add_tag_no_hl( + torrent=torrent, + tracker=tracker, + category=category, + max_ratio=max_ratio, + max_seeding_time=max_seeding_time, + add_tag=True, + ) + + # Deletes torrent with data if cleanup is set to true and meets the ratio/seeding requirements + if nohardlinks[category]["cleanup"] and len(nohardlinks[category]) > 0: + tor_reach_seed_limit = self.qbt.has_reached_seed_limit( + torrent, + max_ratio, + max_seeding_time, + min_seeding_time, + nohardlinks[category]["resume_torrent_after_untagging_noHL"], + tracker["url"], + ) + if tor_reach_seed_limit: + if torrent.hash not in self.tdel_dict: + self.tdel_dict[torrent.hash] = {} + self.tdel_dict[torrent.hash]["content_path"] = torrent["content_path"].replace( + self.root_dir, self.remote_dir + ) + self.tdel_dict[torrent.hash]["body"] = tor_reach_seed_limit + else: + # Updates torrent to see if "MinSeedTimeNotReached" tag has been added + torrent = self.qbt.get_torrents({"torrent_hashes": [torrent.hash]}).data[0] + # Checks to see if previously nohardlinks_tag share limits have changed. + self.add_tag_no_hl( + torrent=torrent, + tracker=tracker, + category=category, + max_ratio=max_ratio, + max_seeding_time=max_seeding_time, + add_tag=False, + ) + self.check_previous_nohardlinks_tagged_torrents(has_nohardlinks, torrent, tracker, category) + self.cleanup_tagged_torrents_with_no_hardlinks(category) + if self.stats_tagged >= 1: + logger.print_line( + f"{'Did not Tag/set' if self.config.dry_run else 'Tag/set'} share limits for {self.stats_tagged} " + f".torrent{'s.' if self.stats_tagged > 1 else '.'}", + self.config.loglevel, + ) + else: + logger.print_line("No torrents to tag with no hardlinks.", self.config.loglevel) + if self.stats_untagged >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} " + f"{self.nohardlinks_tag} tags / share limits for {self.stats_untagged} " + f".torrent{'s.' if self.stats_untagged > 1 else '.'}", + self.config.loglevel, + ) + if self.stats_deleted >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} {self.stats_deleted} " + f".torrent{'s' if self.stats_deleted > 1 else ''} but not content files.", + self.config.loglevel, + ) + if self.stats_deleted_contents >= 1: + logger.print_line( + f"{'Did not delete' if self.config.dry_run else 'Deleted'} {self.stats_deleted_contents} " + f".torrent{'s' if self.stats_deleted_contents > 1 else ''} AND content files.", + self.config.loglevel, + ) diff --git a/modules/core/tags.py b/modules/core/tags.py new file mode 100644 index 0000000..4f67cf1 --- /dev/null +++ b/modules/core/tags.py @@ -0,0 +1,61 @@ +from modules import util + +logger = util.logger + + +class Tags: + def __init__(self, qbit_manager): + self.qbt = qbit_manager + self.config = qbit_manager.config + self.client = qbit_manager.client + self.stats = 0 + + self.tags() + + def tags(self): + """Update tags for torrents""" + ignore_tags = self.config.settings["ignoreTags_OnUpdate"] + logger.separator("Updating Tags", space=False, border=False) + for torrent in self.qbt.torrent_list: + check_tags = util.get_list(torrent.tags) + if torrent.tags == "" or (len([trk for trk in check_tags if trk not in ignore_tags]) == 0): + tracker = self.qbt.get_tags(torrent.trackers) + if tracker["tag"]: + self.stats += len(tracker["tag"]) + body = [] + body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) + body += logger.print_line( + logger.insert_space(f'New Tag{"s" if len(tracker["tag"]) > 1 else ""}: {", ".join(tracker["tag"])}', 8), + self.config.loglevel, + ) + body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + body.extend( + self.qbt.set_tags_and_limits( + torrent, + tracker["max_ratio"], + tracker["max_seeding_time"], + tracker["limit_upload_speed"], + tracker["tag"], + ) + ) + category = self.qbt.get_category(torrent.save_path) if torrent.category == "" else torrent.category + attr = { + "function": "tag_update", + "title": "Updating Tags", + "body": "\n".join(body), + "torrent_name": torrent.name, + "torrent_category": category, + "torrent_tag": ", ".join(tracker["tag"]), + "torrent_tracker": tracker["url"], + "notifiarr_indexer": tracker["notifiarr"], + "torrent_max_ratio": tracker["max_ratio"], + "torrent_max_seeding_time": tracker["max_seeding_time"], + "torrent_limit_upload_speed": tracker["limit_upload_speed"], + } + self.config.send_notifications(attr) + if self.stats >= 1: + logger.print_line( + f"{'Did not update' if self.config.dry_run else 'Updated'} {self.stats} new tags.", self.config.loglevel + ) + else: + logger.print_line("No new torrents to tag.", self.config.loglevel) diff --git a/modules/qbittorrent.py b/modules/qbittorrent.py index 812af84..1ca73fa 100755 --- a/modules/qbittorrent.py +++ b/modules/qbittorrent.py @@ -1,20 +1,19 @@ """Qbittorrent Module""" import os import sys -from collections import Counter from datetime import timedelta -from fnmatch import fnmatch from qbittorrentapi import APIConnectionError from qbittorrentapi import Client -from qbittorrentapi import Conflict409Error from qbittorrentapi import LoginFailed from qbittorrentapi import NotFound404Error +from qbittorrentapi import TrackerStatus from qbittorrentapi import Version from modules import util from modules.util import Failed from modules.util import list_in_text +from modules.util import TorrentMessages logger = util.logger @@ -26,6 +25,7 @@ class Qbt: SUPPORTED_VERSION = Version.latest_supported_app_version() MIN_SUPPORTED_VERSION = "v4.3.0" + TORRENT_DICT_COMMANDS = ["recheck", "cross_seed", "rem_unregistered", "tag_tracker_error", "tag_nohardlinks"] def __init__(self, config, params): self.config = config @@ -83,256 +83,120 @@ class Qbt: self.global_max_seeding_time_enabled = self.client.app.preferences.max_seeding_time_enabled self.global_max_seeding_time = self.client.app.preferences.max_seeding_time - def get_torrent_info(torrent_list): - """ - Will create a 2D Dictionary with the torrent name as the key - torrentdict = {'TorrentName1' : {'Category':'TV', 'save_path':'/data/torrents/TV', 'count':1, 'msg':'[]'...}, - 'TorrentName2' : {'Category':'Movies', 'save_path':'/data/torrents/Movies'}, 'count':2, 'msg':'[]'...} - List of dictionary key definitions - Category = Returns category of the torrent (str) - save_path = Returns the save path of the torrent (str) - count = Returns a count of the total number of torrents with the same name (int) - msg = Returns a list of torrent messages by name (list of str) - status = Returns the list of status numbers of the torrent by name - (0: Tracker is disabled (used for DHT, PeX, and LSD), - 1: Tracker has not been contacted yet, - 2: Tracker has been contacted and is working, - 3: Tracker is updating, - 4: Tracker has been contacted, but it is not working (or doesn't send proper replies) - is_complete = Returns the state of torrent - (Returns True if at least one of the torrent with the State is categorized as Complete.) - first_hash = Returns the hash number of the original torrent (Assuming the torrent list is sorted by date added (Asc)) - Takes in a number n, returns the square of n - """ - torrentdict = {} - t_obj_unreg = [] # list of unregistered torrent objects - t_obj_valid = [] # list of working torrents - t_obj_list = [] # list of all torrent objects - settings = self.config.settings - logger.separator("Checking Settings", space=False, border=False) - if settings["force_auto_tmm"]: - logger.print_line( - "force_auto_tmm set to True. Will force Auto Torrent Management for all torrents.", self.config.loglevel - ) - logger.separator("Gathering Torrent Information", space=True, border=True) - for torrent in torrent_list: - is_complete = False - msg = None - status = None - working_tracker = None - issue = {"potential": False} - if ( - torrent.auto_tmm is False - and settings["force_auto_tmm"] - and torrent.category != "" - and not self.config.dry_run - ): - torrent.set_auto_management(True) - try: - torrent_name = torrent.name - torrent_hash = torrent.hash - torrent_is_complete = torrent.state_enum.is_complete - save_path = torrent.save_path - category = torrent.category - torrent_trackers = torrent.trackers - except Exception as ex: - self.config.notify(ex, "Get Torrent Info", False) - logger.warning(ex) - if torrent_name in torrentdict: - t_obj_list.append(torrent) - t_count = torrentdict[torrent_name]["count"] + 1 - msg_list = torrentdict[torrent_name]["msg"] - status_list = torrentdict[torrent_name]["status"] - is_complete = True if torrentdict[torrent_name]["is_complete"] is True else torrent_is_complete - first_hash = torrentdict[torrent_name]["first_hash"] - else: - t_obj_list = [torrent] - t_count = 1 - msg_list = [] - status_list = [] - is_complete = torrent_is_complete - first_hash = torrent_hash - for trk in torrent_trackers: - if trk.url.startswith("http"): - status = trk.status - msg = trk.msg.upper() - exception = [ - "DOWN", - "DOWN.", - "IT MAY BE DOWN,", - "UNREACHABLE", - "(UNREACHABLE)", - "BAD GATEWAY", - "TRACKER UNAVAILABLE", - ] - if trk.status == 2: - working_tracker = True - break - # Add any potential unregistered torrents to a list - if trk.status == 4 and not list_in_text(msg, exception): - issue["potential"] = True - issue["msg"] = msg - issue["status"] = status - if working_tracker: - status = 2 - msg = "" - t_obj_valid.append(torrent) - elif issue["potential"]: - status = issue["status"] - msg = issue["msg"] - t_obj_unreg.append(torrent) - if msg is not None: - msg_list.append(msg) - if status is not None: - status_list.append(status) - torrentattr = { - "torrents": t_obj_list, - "Category": category, - "save_path": save_path, - "count": t_count, - "msg": msg_list, - "status": status_list, - "is_complete": is_complete, - "first_hash": first_hash, - } - torrentdict[torrent_name] = torrentattr - return torrentdict, t_obj_unreg, t_obj_valid - - self.torrentinfo = None - self.torrentissue = None - self.torrentvalid = None - if ( - config.commands["recheck"] - or config.commands["cross_seed"] - or config.commands["rem_unregistered"] - or config.commands["tag_tracker_error"] - or config.commands["tag_nohardlinks"] - ): + if any(config.commands.get(command, False) for command in self.TORRENT_DICT_COMMANDS): # Get an updated torrent dictionary information of the torrents - self.torrentinfo, self.torrentissue, self.torrentvalid = get_torrent_info(self.torrent_list) + self.get_torrent_info() + else: + self.torrentinfo = None + self.torrentissue = None + self.torrentvalid = None + + def get_torrent_info(self): + """ + Will create a 2D Dictionary with the torrent name as the key + self.torrentinfo = {'TorrentName1' : {'Category':'TV', 'save_path':'/data/torrents/TV', 'count':1, 'msg':'[]'...}, + 'TorrentName2' : {'Category':'Movies', 'save_path':'/data/torrents/Movies'}, 'count':2, 'msg':'[]'...} + List of dictionary key definitions + Category = Returns category of the torrent (str) + save_path = Returns the save path of the torrent (str) + count = Returns a count of the total number of torrents with the same name (int) + msg = Returns a list of torrent messages by name (list of str) + status = Returns the list of status numbers of the torrent by name + (0: Tracker is disabled (used for DHT, PeX, and LSD), + 1: Tracker has not been contacted yet, + 2: Tracker has been contacted and is working, + 3: Tracker is updating, + 4: Tracker has been contacted, but it is not working (or doesn't send proper replies) + is_complete = Returns the state of torrent + (Returns True if at least one of the torrent with the State is categorized as Complete.) + first_hash = Returns the hash number of the original torrent (Assuming the torrent list is sorted by date added (Asc)) + Takes in a number n, returns the square of n + """ + self.torrentinfo = {} + self.torrentissue = [] # list of unregistered torrent objects + self.torrentvalid = [] # list of working torrents + t_obj_list = [] # list of all torrent objects + settings = self.config.settings + logger.separator("Checking Settings", space=False, border=False) + if settings["force_auto_tmm"]: + logger.print_line( + "force_auto_tmm set to True. Will force Auto Torrent Management for all torrents.", self.config.loglevel + ) + logger.separator("Gathering Torrent Information", space=True, border=True) + for torrent in self.torrent_list: + is_complete = False + msg = None + status = None + working_tracker = None + issue = {"potential": False} + if torrent.auto_tmm is False and settings["force_auto_tmm"] and torrent.category != "" and not self.config.dry_run: + torrent.set_auto_management(True) + try: + torrent_name = torrent.name + torrent_hash = torrent.hash + torrent_is_complete = torrent.state_enum.is_complete + save_path = torrent.save_path + category = torrent.category + torrent_trackers = torrent.trackers + except Exception as ex: + self.config.notify(ex, "Get Torrent Info", False) + logger.warning(ex) + if torrent_name in self.torrentinfo: + t_obj_list.append(torrent) + t_count = self.torrentinfo[torrent_name]["count"] + 1 + msg_list = self.torrentinfo[torrent_name]["msg"] + status_list = self.torrentinfo[torrent_name]["status"] + is_complete = True if self.torrentinfo[torrent_name]["is_complete"] is True else torrent_is_complete + first_hash = self.torrentinfo[torrent_name]["first_hash"] + else: + t_obj_list = [torrent] + t_count = 1 + msg_list = [] + status_list = [] + is_complete = torrent_is_complete + first_hash = torrent_hash + for trk in torrent_trackers: + if trk.url.startswith("http"): + status = trk.status + msg = trk.msg.upper() + if TrackerStatus(trk.status) == TrackerStatus.WORKING: + working_tracker = True + break + # Add any potential unregistered torrents to a list + if TrackerStatus(trk.status) == TrackerStatus.NOT_WORKING and not list_in_text( + msg, TorrentMessages.EXCEPTIONS_MSGS + ): + issue["potential"] = True + issue["msg"] = msg + issue["status"] = status + if working_tracker: + status = 2 + msg = "" + self.torrentvalid.append(torrent) + elif issue["potential"]: + status = issue["status"] + msg = issue["msg"] + self.torrentissue.append(torrent) + if msg is not None: + msg_list.append(msg) + if status is not None: + status_list.append(status) + torrentattr = { + "torrents": t_obj_list, + "Category": category, + "save_path": save_path, + "count": t_count, + "msg": msg_list, + "status": status_list, + "is_complete": is_complete, + "first_hash": first_hash, + } + self.torrentinfo[torrent_name] = torrentattr def get_torrents(self, params): """Get torrents from qBittorrent""" return self.client.torrents.info(**params) - def category(self): - """Update category for torrents""" - num_cat = 0 - - def update_cat(new_cat, cat_change): - nonlocal torrent, num_cat - tracker = self.config.get_tags(torrent.trackers) - old_cat = torrent.category - if not self.config.dry_run: - try: - torrent.set_category(category=new_cat) - if torrent.auto_tmm is False and self.config.settings["force_auto_tmm"]: - torrent.set_auto_management(True) - except Conflict409Error: - ex = logger.print_line( - f'Existing category "{new_cat}" not found for save path {torrent.save_path}, category will be created.', - self.config.loglevel, - ) - self.config.notify(ex, "Update Category", False) - self.client.torrent_categories.create_category(name=new_cat, save_path=torrent.save_path) - torrent.set_category(category=new_cat) - body = [] - body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) - if cat_change: - body += logger.print_line(logger.insert_space(f"Old Category: {old_cat}", 3), self.config.loglevel) - title = "Moving Categories" - else: - title = "Updating Categories" - body += logger.print_line(logger.insert_space(f"New Category: {new_cat}", 3), self.config.loglevel) - body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) - attr = { - "function": "cat_update", - "title": title, - "body": "\n".join(body), - "torrent_name": torrent.name, - "torrent_category": new_cat, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - num_cat += 1 - - if self.config.commands["cat_update"]: - logger.separator("Updating Categories", space=False, border=False) - torrent_list = self.get_torrents({"category": "", "status_filter": "completed"}) - for torrent in torrent_list: - new_cat = self.config.get_category(torrent.save_path) - update_cat(new_cat, False) - - # Change categories - if self.config.cat_change: - for old_cat in self.config.cat_change: - torrent_list = self.get_torrents({"category": old_cat, "status_filter": "completed"}) - for torrent in torrent_list: - new_cat = self.config.cat_change[old_cat] - update_cat(new_cat, True) - - if num_cat >= 1: - logger.print_line( - f"{'Did not update' if self.config.dry_run else 'Updated'} {num_cat} new categories.", self.config.loglevel - ) - else: - logger.print_line("No new torrents to categorize.", self.config.loglevel) - return num_cat - - def tags(self): - """Update tags for torrents""" - num_tags = 0 - ignore_tags = self.config.settings["ignoreTags_OnUpdate"] - if self.config.commands["tag_update"]: - logger.separator("Updating Tags", space=False, border=False) - for torrent in self.torrent_list: - check_tags = util.get_list(torrent.tags) - if torrent.tags == "" or (len([trk for trk in check_tags if trk not in ignore_tags]) == 0): - tracker = self.config.get_tags(torrent.trackers) - if tracker["tag"]: - num_tags += len(tracker["tag"]) - body = [] - body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) - body += logger.print_line( - logger.insert_space( - f'New Tag{"s" if len(tracker["tag"]) > 1 else ""}: {", ".join(tracker["tag"])}', 8 - ), - self.config.loglevel, - ) - body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) - body.extend( - self.set_tags_and_limits( - torrent, - tracker["max_ratio"], - tracker["max_seeding_time"], - tracker["limit_upload_speed"], - tracker["tag"], - ) - ) - category = self.config.get_category(torrent.save_path) if torrent.category == "" else torrent.category - attr = { - "function": "tag_update", - "title": "Updating Tags", - "body": "\n".join(body), - "torrent_name": torrent.name, - "torrent_category": category, - "torrent_tag": ", ".join(tracker["tag"]), - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - "torrent_max_ratio": tracker["max_ratio"], - "torrent_max_seeding_time": tracker["max_seeding_time"], - "torrent_limit_upload_speed": tracker["limit_upload_speed"], - } - self.config.send_notifications(attr) - if num_tags >= 1: - logger.print_line( - f"{'Did not update' if self.config.dry_run else 'Updated'} {num_tags} new tags.", self.config.loglevel - ) - else: - logger.print_line("No new torrents to tag.", self.config.loglevel) - return num_tags - def set_tags_and_limits( self, torrent, max_ratio, max_seeding_time, limit_upload_speed=None, tags=None, restore=False, do_print=True ): @@ -460,792 +324,239 @@ class Qbt: return body return False - def tag_nohardlinks(self): - """Tag torrents with no hardlinks""" - num_tags = 0 # counter for the number of torrents that has no hardlinks - del_tor = 0 # counter for the number of torrents that has no hardlinks and \ - # meets the criteria for ratio limit/seed limit for deletion - del_tor_cont = 0 # counter for the number of torrents that has no hardlinks and \ - # meets the criteria for ratio limit/seed limit for deletion including contents - num_untag = 0 # counter for number of torrents that previously had no hardlinks but now have hardlinks - - def add_tag_no_hl(add_tag=True): - """Add tag nohardlinks_tag to torrents with no hardlinks""" - nonlocal num_tags, torrent, tracker, nohardlinks, category, max_ratio, max_seeding_time - body = [] - body.append(logger.insert_space(f"Torrent Name: {torrent.name}", 3)) - if add_tag: - body.append(logger.insert_space(f"Added Tag: {self.config.nohardlinks_tag}", 6)) - title = "Tagging Torrents with No Hardlinks" + def get_tags(self, trackers): + """Get tags from config file based on keyword""" + urls = [x.url for x in trackers if x.url.startswith("http")] + tracker = {} + tracker["tag"] = None + tracker["max_ratio"] = None + tracker["min_seeding_time"] = None + tracker["max_seeding_time"] = None + tracker["limit_upload_speed"] = None + tracker["notifiarr"] = None + tracker["url"] = None + tracker_other_tag = self.config.util.check_for_attribute( + self.config.data, "tag", parent="tracker", subparent="other", default_is_none=True, var_type="list", save=False + ) + try: + tracker["url"] = util.trunc_val(urls[0], os.sep) + except IndexError as e: + tracker["url"] = None + if not urls: + urls = [] + if not tracker_other_tag: + tracker_other_tag = ["other"] + tracker["url"] = "No http URL found" else: - title = "Changing Share Ratio of Torrents with No Hardlinks" - body.append(logger.insert_space(f'Tracker: {tracker["url"]}', 8)) - body_tags_and_limits = self.set_tags_and_limits( - torrent, - max_ratio, - max_seeding_time, - nohardlinks[category]["limit_upload_speed"], - tags=self.config.nohardlinks_tag, - do_print=False, - ) - if body_tags_and_limits or add_tag: - num_tags += 1 - # Resume torrent if it was paused now that the share limit has changed - if torrent.state_enum.is_complete and nohardlinks[category]["resume_torrent_after_untagging_noHL"]: - if not self.config.dry_run: - torrent.resume() - body.extend(body_tags_and_limits) - for rcd in body: - logger.print_line(rcd, self.config.loglevel) - attr = { - "function": "tag_nohardlinks", - "title": title, - "body": "\n".join(body), - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tag": self.config.nohardlinks_tag, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - "torrent_max_ratio": max_ratio, - "torrent_max_seeding_time": max_seeding_time, - "torrent_limit_upload_speed": nohardlinks[category]["limit_upload_speed"], - } - self.config.send_notifications(attr) - - if self.config.commands["tag_nohardlinks"]: - logger.separator("Tagging Torrents with No Hardlinks", space=False, border=False) - nohardlinks = self.config.nohardlinks - tdel_dict = {} # dictionary to track the torrent names and content path that meet the deletion criteria - root_dir = self.config.root_dir - remote_dir = self.config.remote_dir - for category in nohardlinks: - torrent_list = self.get_torrents({"category": category, "status_filter": "completed"}) - if len(torrent_list) == 0: - ex = ( - "No torrents found in the category (" - + category - + ") defined under nohardlinks attribute in the config. " - + "Please check if this matches with any category in qbittorrent and has 1 or more torrents." - ) - logger.warning(ex) - continue - for torrent in torrent_list: - tracker = self.config.get_tags(torrent.trackers) - has_nohardlinks = util.nohardlink(torrent["content_path"].replace(root_dir, remote_dir), self.config.notify) - if any(tag in torrent.tags for tag in nohardlinks[category]["exclude_tags"]): - # Skip to the next torrent if we find any torrents that are in the exclude tag - continue - else: - # Checks for any hardlinks and not already tagged - # Cleans up previously tagged nohardlinks_tag torrents that no longer have hardlinks - if has_nohardlinks: - tracker = self.config.get_tags(torrent.trackers) - # Determine min_seeding_time. - # If only tracker setting is set, use tracker's min_seeding_time - # If only nohardlinks category setting is set, use nohardlinks category's min_seeding_time - # If both tracker and nohardlinks category setting is set, use the larger of the two - # If neither set, use 0 (no limit) - min_seeding_time = 0 - if tracker["min_seeding_time"] is not None and nohardlinks[category]["min_seeding_time"] is not None: - if tracker["min_seeding_time"] >= nohardlinks[category]["min_seeding_time"]: - min_seeding_time = tracker["min_seeding_time"] - elif nohardlinks[category]["min_seeding_time"]: - min_seeding_time = nohardlinks[category]["min_seeding_time"] - elif tracker["min_seeding_time"]: - min_seeding_time = tracker["min_seeding_time"] - # Determine max_ratio. - # If only tracker setting is set, use tracker's max_ratio - # If only nohardlinks category setting is set, use nohardlinks category's max_ratio - # If both tracker and nohardlinks category setting is set, use the larger of the two - # If neither set, use -1 (no limit) - max_ratio = -1 - if tracker["max_ratio"] is not None and nohardlinks[category]["max_ratio"] is not None: - if tracker["max_ratio"] >= nohardlinks[category]["max_ratio"]: - max_ratio = tracker["max_ratio"] - elif nohardlinks[category]["max_ratio"]: - max_ratio = nohardlinks[category]["max_ratio"] - elif tracker["max_ratio"]: - max_ratio = tracker["max_ratio"] - # Determine max_seeding_time. - # If only tracker setting is set, use tracker's max_seeding_time - # If only nohardlinks category setting is set, use nohardlinks category's max_seeding_time - # If both tracker and nohardlinks category setting is set, use the larger of the two - # If neither set, use -1 (no limit) - max_seeding_time = -1 - if tracker["max_seeding_time"] is not None and nohardlinks[category]["max_seeding_time"] is not None: - if tracker["max_seeding_time"] >= nohardlinks[category]["max_seeding_time"]: - max_seeding_time = tracker["max_seeding_time"] - elif nohardlinks[category]["max_seeding_time"]: - max_seeding_time = nohardlinks[category]["max_seeding_time"] - elif tracker["max_seeding_time"]: - max_seeding_time = tracker["max_seeding_time"] - # Will only tag new torrents that don't have nohardlinks_tag tag - if self.config.nohardlinks_tag not in torrent.tags: - add_tag_no_hl(add_tag=True) - - # Deletes torrent with data if cleanup is set to true and meets the ratio/seeding requirements - if nohardlinks[category]["cleanup"] and len(nohardlinks[category]) > 0: - tor_reach_seed_limit = self.has_reached_seed_limit( - torrent, - max_ratio, - max_seeding_time, - min_seeding_time, - nohardlinks[category]["resume_torrent_after_untagging_noHL"], - tracker["url"], - ) - if tor_reach_seed_limit: - if torrent.hash not in tdel_dict: - tdel_dict[torrent.hash] = {} - tdel_dict[torrent.hash]["content_path"] = torrent["content_path"].replace( - root_dir, remote_dir - ) - tdel_dict[torrent.hash]["body"] = tor_reach_seed_limit - else: - # Updates torrent to see if "MinSeedTimeNotReached" tag has been added - torrent = self.get_torrents({"torrent_hashes": [torrent.hash]}).data[0] - # Checks to see if previously nohardlinks_tag share limits have changed. - add_tag_no_hl(add_tag=False) - # Checks to see if previous nohardlinks_tag tagged torrents now have hardlinks. - if not (has_nohardlinks) and (self.config.nohardlinks_tag in torrent.tags): - num_untag += 1 - body = [] - body += logger.print_line( - f"Previous Tagged {self.config.nohardlinks_tag} " - f"Torrent Name: {torrent.name} has hardlinks found now.", - self.config.loglevel, - ) - body += logger.print_line( - logger.insert_space(f"Removed Tag: {self.config.nohardlinks_tag}", 6), self.config.loglevel - ) - body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) - body += logger.print_line( - f"{'Not Reverting' if self.config.dry_run else 'Reverting'} to tracker or Global share limits.", - self.config.loglevel, - ) - restore_max_ratio = tracker["max_ratio"] - restore_max_seeding_time = tracker["max_seeding_time"] - restore_limit_upload_speed = tracker["limit_upload_speed"] - if restore_max_ratio is None: - restore_max_ratio = -2 - if restore_max_seeding_time is None: - restore_max_seeding_time = -2 - if restore_limit_upload_speed is None: - restore_limit_upload_speed = -1 - if not self.config.dry_run: - torrent.remove_tags(tags=self.config.nohardlinks_tag) - body.extend( - self.set_tags_and_limits( - torrent, restore_max_ratio, restore_max_seeding_time, restore_limit_upload_speed, restore=True - ) - ) - if torrent.state_enum.is_complete and nohardlinks[category]["resume_torrent_after_untagging_noHL"]: - torrent.resume() - attr = { - "function": "untag_nohardlinks", - "title": "Untagging Previous Torrents that now have hardlinks", - "body": "\n".join(body), - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tag": self.config.nohardlinks_tag, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - "torrent_max_ratio": restore_max_ratio, - "torrent_max_seeding_time": restore_max_seeding_time, - "torrent_limit_upload_speed": restore_limit_upload_speed, - } - self.config.send_notifications(attr) - # loop through torrent list again for cleanup purposes - if nohardlinks[category]["cleanup"]: - torrent_list = self.get_torrents({"category": category, "status_filter": "completed"}) - for torrent in torrent_list: - t_name = torrent.name - t_hash = torrent.hash - if t_hash in tdel_dict and self.config.nohardlinks_tag in torrent.tags: - t_count = self.torrentinfo[t_name]["count"] - t_msg = self.torrentinfo[t_name]["msg"] - t_status = self.torrentinfo[t_name]["status"] - # Double check that the content path is the same before we delete anything - if torrent["content_path"].replace(root_dir, remote_dir) == tdel_dict[t_hash]["content_path"]: - tracker = self.config.get_tags(torrent.trackers) - body = [] - body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) - body += logger.print_line( - logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel - ) - body += logger.print_line(tdel_dict[t_hash]["body"], self.config.loglevel) - body += logger.print_line( - logger.insert_space("Cleanup: True [No hardlinks found and meets Share Limits.]", 8), - self.config.loglevel, - ) - attr = { - "function": "cleanup_tag_nohardlinks", - "title": "Removing NoHL Torrents and meets Share Limits", - "torrent_name": t_name, - "torrent_category": torrent.category, - "cleanup": "True", - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - if os.path.exists(torrent["content_path"].replace(root_dir, remote_dir)): - # Checks if any of the original torrents are working - if t_count > 1 and ("" in t_msg or 2 in t_status): - del_tor += 1 - attr["torrents_deleted_and_contents"] = False - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line( - logger.insert_space("Deleted .torrent but NOT content files.", 8), - self.config.loglevel, - ) - else: - del_tor_cont += 1 - attr["torrents_deleted_and_contents"] = True - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line( - logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel - ) - else: - del_tor += 1 - attr["torrents_deleted_and_contents"] = False - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line( - logger.insert_space("Deleted .torrent but NOT content files.", 8), self.config.loglevel - ) - attr["body"] = "\n".join(body) - self.config.send_notifications(attr) - self.torrentinfo[t_name]["count"] -= 1 - if num_tags >= 1: - logger.print_line( - f"{'Did not Tag/set' if self.config.dry_run else 'Tag/set'} share limits for {num_tags} " - f".torrent{'s.' if num_tags > 1 else '.'}", - self.config.loglevel, - ) - else: - logger.print_line("No torrents to tag with no hardlinks.", self.config.loglevel) - if num_untag >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} " - f"{self.config.nohardlinks_tag} tags / share limits for {num_untag} " - f".torrent{'s.' if num_untag > 1 else '.'}", - self.config.loglevel, - ) - if del_tor >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} {del_tor} " - f".torrent{'s' if del_tor > 1 else ''} but not content files.", - self.config.loglevel, - ) - if del_tor_cont >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} {del_tor_cont} " - f".torrent{'s' if del_tor_cont > 1 else ''} AND content files.", - self.config.loglevel, - ) - return num_tags, num_untag, del_tor, del_tor_cont - - def rem_unregistered(self): - """Remove torrents with unregistered trackers.""" - del_tor = 0 - del_tor_cont = 0 - num_tor_error = 0 - num_untag = 0 - tor_error_summary = "" - tag_error = self.config.tracker_error_tag - cfg_rem_unregistered = self.config.commands["rem_unregistered"] - cfg_tag_error = self.config.commands["tag_tracker_error"] - - def tag_tracker_error(): - nonlocal t_name, msg_up, msg, tracker, t_cat, torrent, tag_error, tor_error_summary, num_tor_error - tor_error = "" - tor_error += logger.insert_space(f"Torrent Name: {t_name}", 3) + "\n" - tor_error += logger.insert_space(f"Status: {msg}", 9) + "\n" - tor_error += logger.insert_space(f'Tracker: {tracker["url"]}', 8) + "\n" - tor_error += logger.insert_space(f"Added Tag: {tag_error}", 6) + "\n" - tor_error_summary += tor_error - num_tor_error += 1 - attr = { - "function": "tag_tracker_error", - "title": "Tag Tracker Error Torrents", - "body": tor_error, - "torrent_name": t_name, - "torrent_category": t_cat, - "torrent_tag": tag_error, - "torrent_status": msg, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - if not self.config.dry_run: - torrent.add_tags(tags=tag_error) - - def del_unregistered(): - nonlocal del_tor, del_tor_cont, t_name, msg_up, msg, tracker, t_cat, t_msg, t_status, torrent - body = [] - body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) - body += logger.print_line(logger.insert_space(f"Status: {msg}", 9), self.config.loglevel) - body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) - attr = { - "function": "rem_unregistered", - "title": "Removing Unregistered Torrents", - "torrent_name": t_name, - "torrent_category": t_cat, - "torrent_status": msg, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - if t_count > 1: - # Checks if any of the original torrents are working - if "" in t_msg or 2 in t_status: - attr["torrents_deleted_and_contents"] = False - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line( - logger.insert_space("Deleted .torrent but NOT content files.", 8), self.config.loglevel - ) - del_tor += 1 - else: - attr["torrents_deleted_and_contents"] = True - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line(logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel) - del_tor_cont += 1 - else: - attr["torrents_deleted_and_contents"] = True - if not self.config.dry_run: - self.tor_delete_recycle(torrent, attr) - body += logger.print_line(logger.insert_space("Deleted .torrent AND content files.", 8), self.config.loglevel) - del_tor_cont += 1 - attr["body"] = "\n".join(body) - self.config.send_notifications(attr) - self.torrentinfo[t_name]["count"] -= 1 - - if cfg_rem_unregistered or cfg_tag_error: - if cfg_tag_error: - logger.separator("Tagging Torrents with Tracker Errors", space=False, border=False) - elif cfg_rem_unregistered: - logger.separator("Removing Unregistered Torrents", space=False, border=False) - unreg_msgs = [ - "UNREGISTERED", - "TORRENT NOT FOUND", - "TORRENT IS NOT FOUND", - "NOT REGISTERED", - "NOT EXIST", - "UNKNOWN TORRENT", - "TRUMP", - "RETITLED", - "TRUNCATED", - "TORRENT IS NOT AUTHORIZED FOR USE ON THIS TRACKER", - ] - ignore_msgs = [ - "YOU HAVE REACHED THE CLIENT LIMIT FOR THIS TORRENT", - "MISSING PASSKEY", - "MISSING INFO_HASH", - "PASSKEY IS INVALID", - "INVALID PASSKEY", - "EXPECTED VALUE (LIST, DICT, INT OR STRING) IN BENCODED STRING", - "COULD NOT PARSE BENCODED DATA", - "STREAM TRUNCATED", - ] - for torrent in self.torrentvalid: - check_tags = util.get_list(torrent.tags) - # Remove any error torrents Tags that are no longer unreachable. - if tag_error in check_tags: - tracker = self.config.get_tags(torrent.trackers) - num_untag += 1 - body = [] - body += logger.print_line( - f"Previous Tagged {tag_error} torrent currently has a working tracker.", self.config.loglevel - ) - body += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) - body += logger.print_line(logger.insert_space(f"Removed Tag: {tag_error}", 4), self.config.loglevel) - body += logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) - if not self.config.dry_run: - torrent.remove_tags(tags=tag_error) - attr = { - "function": "untag_tracker_error", - "title": "Untagging Tracker Error Torrent", - "body": "\n".join(body), - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tag": tag_error, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - for torrent in self.torrentissue: - t_name = torrent.name - t_cat = self.torrentinfo[t_name]["Category"] - t_count = self.torrentinfo[t_name]["count"] - t_msg = self.torrentinfo[t_name]["msg"] - t_status = self.torrentinfo[t_name]["status"] - check_tags = util.get_list(torrent.tags) - try: - for trk in torrent.trackers: - if trk.url.startswith("http"): - tracker = self.config.get_tags([trk]) - msg_up = trk.msg.upper() - msg = trk.msg - # Tag any error torrents - if cfg_tag_error: - if trk.status == 4 and tag_error not in check_tags: - tag_tracker_error() - if cfg_rem_unregistered: - # Tag any error torrents that are not unregistered - if not list_in_text(msg_up, unreg_msgs) and trk.status == 4 and tag_error not in check_tags: - # Check for unregistered torrents using BHD API if the tracker is BHD - if ( - "tracker.beyond-hd.me" in tracker["url"] - and self.config.beyond_hd is not None - and not list_in_text(msg_up, ignore_msgs) - ): - json = {"info_hash": torrent.hash} - response = self.config.beyond_hd.search(json) - if response["total_results"] == 0: - del_unregistered() - break - tag_tracker_error() - if list_in_text(msg_up, unreg_msgs) and not list_in_text(msg_up, ignore_msgs) and trk.status == 4: - del_unregistered() - break - except NotFound404Error: - continue - except Exception as ex: - logger.stacktrace() - self.config.notify(ex, "Remove Unregistered Torrents", False) - logger.error(f"Remove Unregistered Torrents Error: {ex}") - if cfg_rem_unregistered: - if del_tor >= 1 or del_tor_cont >= 1: - if del_tor >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} {del_tor} " - f".torrent{'s' if del_tor > 1 else ''} but not content files.", - self.config.loglevel, - ) - if del_tor_cont >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} {del_tor_cont} " - f".torrent{'s' if del_tor_cont > 1 else ''} AND content files.", - self.config.loglevel, - ) - else: - logger.print_line("No unregistered torrents found.", self.config.loglevel) - if num_untag >= 1: - logger.print_line( - f"{'Did not delete' if self.config.dry_run else 'Deleted'} {tag_error} tags for {num_untag} " - f".torrent{'s.' if num_untag > 1 else '.'}", - self.config.loglevel, - ) - if num_tor_error >= 1: - logger.separator( - f"{num_tor_error} Torrents with tracker errors found", - space=False, - border=False, - loglevel=self.config.loglevel, - ) - logger.print_line(tor_error_summary.rstrip(), self.config.loglevel) - return del_tor, del_tor_cont, num_tor_error, num_untag - - def cross_seed(self): - """Move torrents from cross seed directory to correct save directory.""" - added = 0 # Keep track of total torrents tagged - tagged = 0 # Track # of torrents tagged that are not cross-seeded - if self.config.commands["cross_seed"]: - logger.separator("Checking for Cross-Seed Torrents", space=False, border=False) - # List of categories for all torrents moved - categories = [] - - # Only get torrent files - cs_files = [f for f in os.listdir(self.config.cross_seed_dir) if f.endswith("torrent")] - dir_cs = self.config.cross_seed_dir - dir_cs_out = os.path.join(dir_cs, "qbit_manage_added") - os.makedirs(dir_cs_out, exist_ok=True) - for file in cs_files: - tr_name = file.split("]", 2)[2].split(".torrent")[0] - t_tracker = file.split("]", 2)[1][1:] - # Substring Key match in dictionary (used because t_name might not match exactly with torrentdict key) - # Returned the dictionary of filtered item - torrentdict_file = dict(filter(lambda item: tr_name in item[0], self.torrentinfo.items())) - if torrentdict_file: - # Get the exact torrent match name from torrentdict - t_name = next(iter(torrentdict_file)) - dest = os.path.join(self.torrentinfo[t_name]["save_path"], "") - src = os.path.join(dir_cs, file) - dir_cs_out = os.path.join(dir_cs, "qbit_manage_added", file) - category = self.config.get_category(dest) - # Only add cross-seed torrent if original torrent is complete - if self.torrentinfo[t_name]["is_complete"]: - categories.append(category) - body = [] - body += logger.print_line( - f"{'Not Adding' if self.config.dry_run else 'Adding'} to qBittorrent:", self.config.loglevel - ) - body += logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) - body += logger.print_line(logger.insert_space(f"Category: {category}", 7), self.config.loglevel) - body += logger.print_line(logger.insert_space(f"Save_Path: {dest}", 6), self.config.loglevel) - body += logger.print_line(logger.insert_space(f"Tracker: {t_tracker}", 8), self.config.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, - "torrent_tag": "cross-seed", - "torrent_tracker": t_tracker, - } - self.config.send_notifications(attr) - added += 1 - if not self.config.dry_run: - self.client.torrents.add( - torrent_files=src, save_path=dest, category=category, tags="cross-seed", is_paused=True - ) - util.move_files(src, dir_cs_out) - else: - logger.print_line( - f"Found {t_name} in {dir_cs} but original torrent is not complete.", self.config.loglevel - ) - logger.print_line("Not adding to qBittorrent", self.config.loglevel) - else: - error = f"{t_name} not found in torrents. Cross-seed Torrent not added to qBittorrent." - if self.config.dry_run: - logger.print_line(error, self.config.loglevel) - else: - logger.print_line(error, "WARNING") - self.config.notify(error, "cross-seed", False) - # Tag missing cross-seed torrents tags - for torrent in self.torrent_list: - t_name = torrent.name - t_cat = torrent.category - if ( - "cross-seed" not in torrent.tags - and self.torrentinfo[t_name]["count"] > 1 - and self.torrentinfo[t_name]["first_hash"] != torrent.hash - ): - tracker = self.config.get_tags(torrent.trackers) - tagged += 1 - body = logger.print_line( - f"{'Not Adding' if self.config.dry_run else 'Adding'} 'cross-seed' tag to {t_name}", self.config.loglevel - ) - attr = { - "function": "tag_cross_seed", - "title": "Tagging Cross-Seed Torrent", - "body": body, - "torrent_name": t_name, - "torrent_category": t_cat, - "torrent_tag": "cross-seed", - "torrent_tracker": tracker, - } - self.config.send_notifications(attr) - if not self.config.dry_run: - torrent.add_tags(tags="cross-seed") - - numcategory = Counter(categories) - for cat in numcategory: - if numcategory[cat] > 0: - logger.print_line( - f"{numcategory[cat]} {cat} cross-seed .torrents {'not added' if self.config.dry_run else 'added'}.", - self.config.loglevel, - ) - if added > 0: - logger.print_line( - f"Total {added} cross-seed .torrents {'not added' if self.config.dry_run else 'added'}.", self.config.loglevel - ) - if tagged > 0: - logger.print_line( - f"Total {tagged} cross-seed .torrents {'not tagged' if self.config.dry_run else 'tagged'}.", - self.config.loglevel, - ) - return added, tagged - - def recheck(self): - """Function used to recheck paused torrents sorted by size and resume torrents that are completed""" - resumed = 0 - rechecked = 0 - if self.config.commands["recheck"]: - logger.separator("Rechecking Paused Torrents", space=False, border=False) - # sort by size and paused - torrent_list = self.get_torrents({"status_filter": "paused", "sort": "size"}) - if torrent_list: - for torrent in torrent_list: - tracker = self.config.get_tags(torrent.trackers) - # Resume torrent if completed - if torrent.progress == 1: - if torrent.max_ratio < 0 and torrent.max_seeding_time < 0: - resumed += 1 - body = logger.print_line( - f"{'Not Resuming' if self.config.dry_run else 'Resuming'} [{tracker['tag']}] - {torrent.name}", - self.config.loglevel, - ) - attr = { - "function": "recheck", - "title": "Resuming Torrent", - "body": body, - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - if not self.config.dry_run: - torrent.resume() + logger.debug(f"Tracker Url:{urls}") + logger.debug(e) + if "tracker" in self.config.data and self.config.data["tracker"] is not None: + tag_values = self.config.data["tracker"] + for tag_url, tag_details in tag_values.items(): + for url in urls: + if tag_url in url: + if tracker["url"] is None: + default_tag = tracker_other_tag else: - # Check to see if torrent meets AutoTorrentManagement criteria - logger.debug("DEBUG: Torrent to see if torrent meets AutoTorrentManagement Criteria") - logger.debug(logger.insert_space(f"- Torrent Name: {torrent.name}", 2)) - logger.debug( - logger.insert_space(f"-- Ratio vs Max Ratio: {torrent.ratio:.2f} < {torrent.max_ratio:.2f}", 4) + try: + tracker["url"] = util.trunc_val(url, os.sep) + default_tag = tracker["url"].split(os.sep)[2].split(":")[0] + except IndexError as e: + logger.debug(f"Tracker Url:{url}") + logger.debug(e) + # Tracker Format 1 deprecated. + if isinstance(tag_details, str): + e = ( + "Config Error: Tracker format invalid. Please see config.yml.sample for correct format and fix " + f"`{tag_details}` in the Tracker section of the config." ) - logger.debug( - logger.insert_space( - f"-- Seeding Time vs Max Seed Time: {timedelta(seconds=torrent.seeding_time)} < " - f"{timedelta(minutes=torrent.max_seeding_time)}", - 4, - ) + self.config.notify(e, "Config") + raise Failed(e) + # Using new Format + else: + tracker["tag"] = self.config.util.check_for_attribute( + self.config.data, "tag", parent="tracker", subparent=tag_url, default=tag_url, var_type="list" ) - if ( - (torrent.max_ratio >= 0 and torrent.ratio < torrent.max_ratio and torrent.max_seeding_time < 0) - or ( - torrent.max_seeding_time >= 0 - and (torrent.seeding_time < (torrent.max_seeding_time * 60)) - and torrent.max_ratio < 0 + if tracker["tag"] == [tag_url]: + self.config.data["tracker"][tag_url]["tag"] = [tag_url] + if isinstance(tracker["tag"], str): + tracker["tag"] = [tracker["tag"]] + is_max_ratio_defined = self.config.data["tracker"].get("max_ratio") + is_max_seeding_time_defined = self.config.data["tracker"].get("max_seeding_time") + if is_max_ratio_defined or is_max_seeding_time_defined: + tracker["max_ratio"] = self.config.util.check_for_attribute( + self.config.data, + "max_ratio", + parent="tracker", + subparent=tag_url, + var_type="float", + min_int=-2, + do_print=False, + default=-1, + save=False, ) - 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)) + tracker["max_seeding_time"] = self.config.util.check_for_attribute( + self.config.data, + "max_seeding_time", + parent="tracker", + subparent=tag_url, + var_type="int", + min_int=-2, + do_print=False, + default=-1, + save=False, ) - ): - resumed += 1 - body = logger.print_line( - f"{'Not Resuming' if self.config.dry_run else 'Resuming'} [{tracker['tag']}] - " - f"{torrent.name}", - self.config.loglevel, + else: + tracker["max_ratio"] = self.config.util.check_for_attribute( + self.config.data, + "max_ratio", + parent="tracker", + subparent=tag_url, + var_type="float", + min_int=-2, + do_print=False, + default_is_none=True, + save=False, ) - attr = { - "function": "recheck", - "title": "Resuming Torrent", - "body": body, - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - if not self.config.dry_run: - torrent.resume() - # Recheck - elif ( - torrent.progress == 0 - and self.torrentinfo[torrent.name]["is_complete"] - and not torrent.state_enum.is_checking - ): - rechecked += 1 - body = logger.print_line( - f"{'Not Rechecking' if self.config.dry_run else 'Rechecking'} [{tracker['tag']}] - {torrent.name}", - self.config.loglevel, - ) - attr = { - "function": "recheck", - "title": "Rechecking Torrent", - "body": body, - "torrent_name": torrent.name, - "torrent_category": torrent.category, - "torrent_tracker": tracker["url"], - "notifiarr_indexer": tracker["notifiarr"], - } - self.config.send_notifications(attr) - if not self.config.dry_run: - torrent.recheck() - return resumed, rechecked - - def rem_orphaned(self): - """Remove orphaned files from remote directory""" - orphaned = 0 - if self.config.commands["rem_orphaned"]: - logger.separator("Checking for Orphaned Files", space=False, border=False) - torrent_files = [] - root_files = [] - orphaned_files = [] - excluded_orphan_files = [] - orphaned_parent_path = set() - remote_path = self.config.remote_dir - root_path = self.config.root_dir - orphaned_path = self.config.orphaned_dir - if remote_path != root_path: - root_files = [ - os.path.join(path.replace(remote_path, root_path), name) - for path, subdirs, files in os.walk(remote_path) - for name in files - if orphaned_path.replace(remote_path, root_path) not in path - ] - else: - root_files = [ - os.path.join(path, name) - for path, subdirs, files in os.walk(root_path) - for name in files - if orphaned_path.replace(root_path, remote_path) not in path - ] - - # Get an updated list of torrents - torrent_list = self.get_torrents({"sort": "added_on"}) - for torrent in torrent_list: - for file in torrent.files: - fullpath = os.path.join(torrent.save_path, file.name) - # Replace fullpath with \\ if qbm is running in docker (linux) but qbt is on windows - fullpath = fullpath.replace(r"/", "\\") if ":\\" in fullpath else fullpath - torrent_files.append(fullpath) - - orphaned_files = set(root_files) - set(torrent_files) - orphaned_files = sorted(orphaned_files) - - if self.config.orphaned["exclude_patterns"]: - exclude_patterns = self.config.orphaned["exclude_patterns"] - excluded_orphan_files = [ - file - for file in orphaned_files - for exclude_pattern in exclude_patterns - if fnmatch(file, exclude_pattern.replace(remote_path, root_path)) - ] - - orphaned_files = set(orphaned_files) - set(excluded_orphan_files) - - if orphaned_files: - os.makedirs(orphaned_path, exist_ok=True) - body = [] - num_orphaned = len(orphaned_files) - logger.print_line(f"{num_orphaned} Orphaned files found", self.config.loglevel) - body += logger.print_line("\n".join(orphaned_files), self.config.loglevel) - body += logger.print_line( - f"{'Did not move' if self.config.dry_run else 'Moved'} {num_orphaned} Orphaned files " - f"to {orphaned_path.replace(remote_path,root_path)}", - self.config.loglevel, + tracker["max_seeding_time"] = self.config.util.check_for_attribute( + self.config.data, + "max_seeding_time", + parent="tracker", + subparent=tag_url, + var_type="int", + min_int=-2, + do_print=False, + default_is_none=True, + save=False, + ) + tracker["min_seeding_time"] = self.config.util.check_for_attribute( + self.config.data, + "min_seeding_time", + parent="tracker", + subparent=tag_url, + var_type="int", + min_int=0, + do_print=False, + default=0, + save=False, + ) + tracker["limit_upload_speed"] = self.config.util.check_for_attribute( + self.config.data, + "limit_upload_speed", + parent="tracker", + subparent=tag_url, + var_type="int", + min_int=-1, + do_print=False, + default=0, + save=False, + ) + tracker["notifiarr"] = self.config.util.check_for_attribute( + self.config.data, + "notifiarr", + parent="tracker", + subparent=tag_url, + default_is_none=True, + do_print=False, + save=False, + ) + return tracker + if tracker_other_tag: + tracker["tag"] = tracker_other_tag + tracker["max_ratio"] = self.config.util.check_for_attribute( + self.config.data, + "max_ratio", + parent="tracker", + subparent="other", + var_type="float", + min_int=-2, + do_print=False, + default=-1, + save=False, ) - - attr = { - "function": "rem_orphaned", - "title": f"Removing {num_orphaned} Orphaned Files", - "body": "\n".join(body), - "orphaned_files": list(orphaned_files), - "orphaned_directory": orphaned_path.replace(remote_path, root_path), - "total_orphaned_files": num_orphaned, - } - self.config.send_notifications(attr) - # Delete empty directories after moving orphan files - logger.info("Cleaning up any empty directories...") - if not self.config.dry_run: - for file in orphaned_files: - src = file.replace(root_path, remote_path) - dest = os.path.join(orphaned_path, file.replace(root_path, "")) - util.move_files(src, dest, True) - orphaned_parent_path.add(os.path.dirname(file).replace(root_path, remote_path)) - for parent_path in orphaned_parent_path: - util.remove_empty_directories(parent_path, "**/*") + tracker["min_seeding_time"] = self.config.util.check_for_attribute( + self.config.data, + "min_seeding_time", + parent="tracker", + subparent="other", + var_type="int", + min_int=0, + do_print=False, + default=-1, + save=False, + ) + tracker["max_seeding_time"] = self.config.util.check_for_attribute( + self.config.data, + "max_seeding_time", + parent="tracker", + subparent="other", + var_type="int", + min_int=-2, + do_print=False, + default=-1, + save=False, + ) + tracker["limit_upload_speed"] = self.config.util.check_for_attribute( + self.config.data, + "limit_upload_speed", + parent="tracker", + subparent="other", + var_type="int", + min_int=-1, + do_print=False, + default=0, + save=False, + ) + tracker["notifiarr"] = self.config.util.check_for_attribute( + self.config.data, + "notifiarr", + parent="tracker", + subparent="other", + default_is_none=True, + do_print=False, + save=False, + ) + return tracker + if tracker["url"]: + logger.trace(f"tracker url: {tracker['url']}") + if tracker_other_tag: + default_tag = tracker_other_tag else: - logger.print_line("No Orphaned Files found.", self.config.loglevel) - return orphaned + default_tag = tracker["url"].split(os.sep)[2].split(":")[0] + tracker["tag"] = self.config.util.check_for_attribute( + self.config.data, "tag", parent="tracker", subparent=default_tag, default=default_tag, var_type="list" + ) + if isinstance(tracker["tag"], str): + tracker["tag"] = [tracker["tag"]] + try: + self.config.data["tracker"][default_tag]["tag"] = [default_tag] + except Exception: + self.config.data["tracker"][default_tag] = {"tag": [default_tag]} + e = f'No tags matched for {tracker["url"]}. Please check your config.yml file. Setting tag to {default_tag}' + self.config.notify(e, "Tag", False) + logger.warning(e) + return tracker + + def get_category(self, path): + """Get category from config file based on path provided""" + category = "" + path = os.path.join(path, "") + if "cat" in self.config.data and self.config.data["cat"] is not None: + cat_path = self.config.data["cat"] + for cat, save_path in cat_path.items(): + if os.path.join(save_path, "") == path: + category = cat + break + + if not category: + default_cat = path.split(os.sep)[-2] + category = str(default_cat) + self.config.util.check_for_attribute(self.config.data, default_cat, parent="cat", default=path) + self.config.data["cat"][str(default_cat)] = path + e = f"No categories matched for the save path {path}. Check your config.yml file. - Setting category to {default_cat}" + self.config.notify(e, "Category", False) + logger.warning(e) + return category def tor_delete_recycle(self, torrent, info): """Move torrent to recycle bin""" diff --git a/modules/util.py b/modules/util.py index ad42ba0..0aa1732 100755 --- a/modules/util.py +++ b/modules/util.py @@ -33,6 +33,44 @@ def get_list(data, lower=False, split=True, int_list=False): return [d.strip() for d in str(data).split(",")] +class TorrentMessages: + """Contains list of messages to check against a status of a torrent""" + + UNREGISTERED_MSGS = [ + "UNREGISTERED", + "TORRENT NOT FOUND", + "TORRENT IS NOT FOUND", + "NOT REGISTERED", + "NOT EXIST", + "UNKNOWN TORRENT", + "TRUMP", + "RETITLED", + "TRUNCATED", + "TORRENT IS NOT AUTHORIZED FOR USE ON THIS TRACKER", + ] + + IGNORE_MSGS = [ + "YOU HAVE REACHED THE CLIENT LIMIT FOR THIS TORRENT", + "MISSING PASSKEY", + "MISSING INFO_HASH", + "PASSKEY IS INVALID", + "INVALID PASSKEY", + "EXPECTED VALUE (LIST, DICT, INT OR STRING) IN BENCODED STRING", + "COULD NOT PARSE BENCODED DATA", + "STREAM TRUNCATED", + ] + + EXCEPTIONS_MSGS = [ + "DOWN", + "DOWN.", + "IT MAY BE DOWN,", + "UNREACHABLE", + "(UNREACHABLE)", + "BAD GATEWAY", + "TRACKER UNAVAILABLE", + ] + + class check: """Check for attributes in config.""" diff --git a/qbit_manage.py b/qbit_manage.py index c2c6742..0ca6f61 100755 --- a/qbit_manage.py +++ b/qbit_manage.py @@ -287,6 +287,13 @@ util.logger = logger from modules.config import Config # noqa from modules.util import GracefulKiller # noqa from modules.util import Failed # noqa +from modules.core.category import Category # noqa +from modules.core.tags import Tags # noqa +from modules.core.remove_unregistered import RemoveUnregistered # noqa +from modules.core.cross_seed import CrossSeed # noqa +from modules.core.recheck import ReCheck # noqa +from modules.core.tag_nohardlinks import TagNoHardLinks # noqa +from modules.core.remove_orphaned import RemoveOrphaned # noqa def my_except_hook(exctype, value, tbi): @@ -369,6 +376,8 @@ def start(): try: cfg = Config(default_dir, args) + qbit_manager = cfg.qbt + except Exception as ex: if "Qbittorrent Error" in ex.args[0]: logger.print_line(ex, "CRITICAL") @@ -379,53 +388,55 @@ def start(): logger.stacktrace() logger.print_line(ex, "CRITICAL") - if cfg: + if qbit_manager: # Set Category - num_categorized = cfg.qbt.category() - stats["categorized"] += num_categorized + if cfg.commands["cat_update"]: + stats["categorized"] += Category(qbit_manager).stats # Set Tags - num_tagged = cfg.qbt.tags() - stats["tagged"] += num_tagged + if cfg.commands["tag_update"]: + stats["tagged"] += Tags(qbit_manager).stats - # Remove Unregistered Torrents - num_deleted, num_deleted_contents, num_tagged, num_untagged = cfg.qbt.rem_unregistered() - stats["rem_unreg"] += num_deleted + num_deleted_contents - stats["deleted"] += num_deleted - stats["deleted_contents"] += num_deleted_contents - stats["tagged_tracker_error"] += num_tagged - stats["untagged_tracker_error"] += num_untagged - stats["tagged"] += num_tagged + # Remove Unregistered Torrents and tag errors + if cfg.commands["rem_unregistered"] or cfg.commands["tag_tracker_error"]: + rem_unreg = RemoveUnregistered(qbit_manager) + stats["rem_unreg"] += rem_unreg.stats_deleted + rem_unreg.stats_deleted_contents + stats["deleted"] += rem_unreg.stats_deleted + stats["deleted_contents"] += rem_unreg.stats_deleted_contents + stats["tagged_tracker_error"] += rem_unreg.stats_tagged + stats["untagged_tracker_error"] += rem_unreg.stats_untagged + stats["tagged"] += rem_unreg.stats_tagged # Set Cross Seed - num_added, num_tagged = cfg.qbt.cross_seed() - stats["added"] += num_added - stats["tagged"] += num_tagged + if cfg.commands["cross_seed"]: + cross_seed = CrossSeed(qbit_manager) + stats["added"] += cross_seed.stats_added + stats["tagged"] += cross_seed.stats_tagged # Recheck Torrents - num_resumed, num_rechecked = cfg.qbt.recheck() - stats["resumed"] += num_resumed - stats["rechecked"] += num_rechecked + if cfg.commands["recheck"]: + recheck = ReCheck(qbit_manager) + stats["resumed"] += recheck.stats_resumed + stats["rechecked"] += recheck.stats_rechecked # Tag NoHardLinks - num_tagged, num_untagged, num_deleted, num_deleted_contents = cfg.qbt.tag_nohardlinks() - stats["tagged"] += num_tagged - stats["tagged_noHL"] += num_tagged - stats["untagged_noHL"] += num_untagged - stats["deleted"] += num_deleted - stats["deleted_contents"] += num_deleted_contents + if cfg.commands["tag_nohardlinks"]: + no_hardlinks = TagNoHardLinks(qbit_manager) + stats["tagged"] += no_hardlinks.stats_tagged + stats["tagged_noHL"] += no_hardlinks.stats_tagged + stats["untagged_noHL"] += no_hardlinks.stats_untagged + stats["deleted"] += no_hardlinks.stats_deleted + stats["deleted_contents"] += no_hardlinks.stats_deleted_contents # Remove Orphaned Files - num_orphaned = cfg.qbt.rem_orphaned() - stats["orphaned"] += num_orphaned + if cfg.commands["rem_orphaned"]: + stats["orphaned"] += RemoveOrphaned(qbit_manager).stats # Empty RecycleBin - recycle_emptied = cfg.cleanup_dirs("Recycle Bin") - stats["recycle_emptied"] += recycle_emptied + stats["recycle_emptied"] += cfg.cleanup_dirs("Recycle Bin") # Empty Orphaned Directory - orphaned_emptied = cfg.cleanup_dirs("Orphaned Data") - stats["orphaned_emptied"] += orphaned_emptied + stats["orphaned_emptied"] += cfg.cleanup_dirs("Orphaned Data") if stats["categorized"] > 0: stats_summary.append(f"Total Torrents Categorized: {stats['categorized']}") diff --git a/requirements.txt b/requirements.txt index 114cd69..f886550 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flake8==6.0.0 pre-commit==3.2.2 -qbittorrent-api==2023.3.44 +qbittorrent-api==2023.4.45 requests==2.28.2 retrying==1.3.4 ruamel.yaml==0.17.21 -schedule==1.1.0 +schedule==1.2.0