From fbf9cb59e9c56b1419541ecf1d4d42a63c53bc67 Mon Sep 17 00:00:00 2001 From: bobokun Date: Tue, 30 May 2023 21:26:54 -0400 Subject: [PATCH] Adds new command share_limits to update share limits based on tags/categories specified per group (Closes #88, Closes #306, Closes #259, Closes #308, Closes #137) --- CHANGELOG | 14 +- VERSION | 2 +- config/config.yml.sample | 106 +++++---- modules/apprise.py | 3 + modules/config.py | 160 ++++++++++++- modules/core/share_limits.py | 399 ++++++++++++++++++++++++++++++++ modules/core/tag_nohardlinks.py | 268 ++------------------- modules/core/tags.py | 18 +- modules/qbittorrent.py | 248 +------------------- qbit_manage.py | 23 +- 10 files changed, 677 insertions(+), 564 deletions(-) create mode 100644 modules/core/share_limits.py diff --git a/CHANGELOG b/CHANGELOG index 0d60abc..91b3f3b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,8 +1,16 @@ # Requirements Updated -- Updates ruamel.yaml to 0.17.27 +- Updates ruamel.yaml to 0.17.30 +- Updates qbitorrent-api to 2023.5.48 + +# New Features +- Adds new command `share_limits`, `--share-limits` , `QBT_SHARE_LIMITS=True` to update share limits based on tags/categories specified per group (Closes #88, Closes #306, Closes #259, Closes #308, Closes #137) +- Adds new command `skip_qb_version_check`, `--skip-qb-version-check`, `QBT_SKIP_QB_VERSION_CHECK` to bypass qbitorrent compatibility check (unsupported - Thanks to @ftc2 #307) +# Breaking Changes +- `tag_nohardlinks` only updates/removes `noHL` tag. It does not modify or cleanup share_limits anymore. +- `tag_update` only adds tracker tags to torrent. It does not modify or cleanup share_limits anymore. +- Please remove any references to share_limits from your configuration in the tracker/nohardlinks section # Bug Fixes - Fixes #302 -- Adds a way to bypass qbt version check (unsupported - Thanks to @ftc2 #307) -**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v3.6.3...v3.6.4 +**Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v3.6.3...v3.7.0 diff --git a/VERSION b/VERSION index 7025338..edd02d8 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.6.4-develop1 +3.6.4-develop2 diff --git a/config/config.yml.sample b/config/config.yml.sample index 4eca563..66d33cb 100755 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -14,6 +14,8 @@ commands: tag_tracker_error: False rem_orphaned: False tag_nohardlinks: False + share_limits: False + skip_qb_version_check: False skip_cleanup: False qbt: @@ -26,6 +28,7 @@ settings: force_auto_tmm: False # Will force qBittorrent to enable Automatic Torrent Management for each torrent. tracker_error_tag: issue # Will set the tag of any torrents that do not have a working tracker. nohardlinks_tag: noHL # Will set the tag of any torrents with no hardlinks. + share_limits_suffix_tag: share_limit # Will add this suffix to the grouping separated by '.' to the tag of any torrents with share limits. ignoreTags_OnUpdate: # When running tag-update function, it will update torrent tags for a given torrent even if the torrent has at least one or more of the tags defined here. Otherwise torrents will not be tagged if tags exist. - noHL - issue @@ -68,14 +71,6 @@ tracker: # : # This is the keyword in the tracker url # Set tag name. Can be a list of tags or a single tag # tag: - # Will set the torrent Maximum share ratio until torrent is stopped from seeding/uploading. -2 means the global limit should be used, -1 means no limit. - # max_ratio: 5.0 - # Will set the torrent Maximum seeding time (min) until torrent is stopped from seeding. -2 means the global limit should be used, -1 means no limit. - # max_seeding_time: 129600 - # Will ensure that noHL torrents from this tracker are not deleted by cleanup variable if torrent has not yet met the minimum seeding time (min). - # min_seeding_time: 2000 - # Will limit the upload speed KiB/s (KiloBytes/second) (-1 means no limit) - # limit_upload_speed: 150 # Set this to the notifiarr react name. This is used to add indexer reactions to the notifications sent by Notifiarr # notifiarr: animebytes.tv: @@ -86,10 +81,6 @@ tracker: - Avistaz - tag2 - tag3 - max_ratio: 5.0 - max_seeding_time: 129600 - min_seeding_time: 30400 - limit_upload_speed: 150 notifiarr: avistaz beyond-hd: tag: [Beyond-HD, tag2, tag3] @@ -101,14 +92,11 @@ tracker: tag: CartoonChaos digitalcore: tag: DigitalCore - max_ratio: 5.0 notifiarr: digitalcore gazellegames: tag: GGn - limit_upload_speed: 150 hdts: tag: HDTorrents - max_seeding_time: 129600 landof.tv: tag: BroadcasTheNet notifiarr: broadcasthenet @@ -145,48 +133,66 @@ nohardlinks: - Beyond-HD - AnimeBytes - MaM - # cleanup var: WARNING!! Setting this as true Will remove and delete contents of any torrents that have a noHL tag and meets share limits - cleanup: false - # max_ratio var: Will set the torrent Maximum share ratio until torrent is stopped from seeding/uploading. - # Delete this key from a category's config to use the tracker's configured max_ratio. Will default to -1 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. - max_ratio: 4.0 - # max seeding time var: Will set the torrent Maximum seeding time (min) until torrent is stopped from seeding. - # Delete this key from a category's config to use the tracker's configured max_seeding_time. Will default to -1 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. - max_seeding_time: 86400 - # Limit Upload Speed var: Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit) - limit_upload_speed: - # min seeding time var: Will prevent torrent deletion by cleanup variable if torrent has not yet minimum seeding time (min). - # Delete this key from a category's config to use the tracker's configured min_seeding_time. Will default to 0 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. - min_seeding_time: 43200 - # resume_torrent_after_untagging_noHL var: If a torrent was previously tagged as NoHL and now has hardlinks, this variable will resume your torrent after changing share limits - resume_torrent_after_untagging_noHL: false # Can have additional categories set with separate ratio/seeding times defined. series-completed: # exclude_tags var: Will exclude torrents with any of the following tags when searching through the category. exclude_tags: - Beyond-HD - BroadcasTheNet - # cleanup var: WARNING!! Setting this as true Will remove and delete contents of any torrents that have a noHL tag and meets share limits - cleanup: false - # max_ratio var: Will set the torrent Maximum share ratio until torrent is stopped from seeding/uploading. - # Delete this key from a category's config to use the tracker's configured max_ratio. Will default to -1 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. - max_ratio: 4.0 - # max seeding time var: Will set the torrent Maximum seeding time (min) until torrent is stopped from seeding. - # Delete this key from a category's config to use the tracker's configured max_seeding_time. Will default to -1 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. - max_seeding_time: 86400 - # Limit Upload Speed var: Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit) - limit_upload_speed: - # min seeding time var: Will prevent torrent deletion by cleanup variable if torrent has not yet minimum seeding time (min). - # Delete this key from a category's config to use the tracker's configured min_seeding_time. Will default to 0 if not specified for the category or tracker. - # Uses the larger value of the noHL Category or Tracker specific setting. + +share_limits: +# Control how torrent share limits are set depending on the priority of your grouping +# This variable is mandatory and is a text defining the name of your grouping. This can be any string you want + noHL: + # priority: # This is the priority of your grouping. The lower the number the higher the priority + priority: 1 + # tags: # Filter the group based on one or more tags. Multiple tags are checked with an AND condition + tags: + - noHL + # exclude_tags: # Filter by excluding one or more tags. Multiple exclude_tags are checked with an AND condition + # This is useful to combine with the category filter to exclude one or more tags from an entire category + exclude_tags: + - Beyond-HD + # categories: # Filter by excluding one or more categories. Multiple exclude_tags are checked with an OR condition + # Since one torrent can only be associated with a single category, multiple categories are checked with an OR condition + categories: + - RadarrComplete + - SonarrComplete + # max_ratio : Will set the torrent Maximum share ratio until torrent is stopped from seeding/uploading. + # Delete this key from a category's config to use the tracker's configured max_ratio. Will default to -1 if not specified for the group. + max_ratio: 5.0 + # max_seeding_time : Will set the torrent Maximum seeding time (minutes) until torrent is stopped from seeding. + # Delete this key from a category's config to use the tracker's configured max_seeding_time. Will default to -1 if not specified for the group. + max_seeding_time: 129600 + # min_seeding_time : Will prevent torrent deletion by cleanup variable if torrent has not yet minimum seeding time (minutes). + # Delete this key from a category's config to use the tracker's configured min_seeding_time. Will default to 0 if not specified for the group. min_seeding_time: 43200 - # resume_torrent_after_untagging_noHL var: If a torrent was previously tagged as NoHL and now has hardlinks, this variable will resume your torrent after changing share limits - resume_torrent_after_untagging_noHL: false + # Limit Upload Speed : Will limit the upload speed KiB/s (KiloBytes/second) (`-1` : No Limit) + limit_upload_speed: 0 + # cleanup : WARNING!! Setting this as true Will remove and delete contents of any torrents that satisfies the share limits + cleanup: false + # resume_torrent_after_change : This variable will resume your torrent after changing share limits. Default is true + resume_torrent_after_change: true + # add_group_to_tag : This adds your grouping as a tag with a suffix defined in settings . Default is true + # Example: A grouping defined as noHL will have a tag set to noHL.share_limit (if using the default suffix) + add_group_to_tag: true + cross-seed: + priority: 2 + tags: cross-seed + max_seeding_time: 10200 + cleanup: false + PTP: + priority: 3 + tags: + - PassThePopcorn + max_ratio: 2.0 + max_seeding_time: 130000 + cleanup: false + default: + priority: 999 + max_ratio: -1 + max_seeding_time: -1 + cleanup: false recyclebin: # Recycle Bin method of deletion will move files into the recycle bin (Located in /root_dir/.RecycleBin) instead of directly deleting them in qbit diff --git a/modules/apprise.py b/modules/apprise.py index 0b7f59f..5ae9c0e 100755 --- a/modules/apprise.py +++ b/modules/apprise.py @@ -1,4 +1,6 @@ """Apprise notification class""" +import time + from modules import util from modules.util import Failed @@ -14,5 +16,6 @@ class Apprise: logger.secret(self.api_url) self.notify_url = ",".join(params["notify_url"]) response = self.config.get(self.api_url) + time.sleep(1) # Pause for 1 second before sending the next request if response.status_code != 200: raise Failed(f"Apprise Error: Unable to connect to Apprise using {self.api_url}") diff --git a/modules/config.py b/modules/config.py index 0ccb440..ec7f9d3 100755 --- a/modules/config.py +++ b/modules/config.py @@ -3,6 +3,7 @@ import os import re import stat import time +from collections import OrderedDict import requests from retrying import retry @@ -28,6 +29,7 @@ COMMANDS = [ "tag_tracker_error", "rem_orphaned", "tag_nohardlinks", + "share_limits", "skip_cleanup", "skip_qb_version_check", "dry_run", @@ -82,6 +84,7 @@ class Config: logger.debug(f" --tag-tracker-error (QBT_TAG_TRACKER_ERROR): {self.commands['tag_tracker_error']}") logger.debug(f" --rem-orphaned (QBT_REM_ORPHANED): {self.commands['rem_orphaned']}") logger.debug(f" --tag-nohardlinks (QBT_TAG_NOHARDLINKS): {self.commands['tag_nohardlinks']}") + logger.debug(f" --share-limits (QBT_SHARE_LIMITS): {self.commands['share_limits']}") logger.debug(f" --skip-cleanup (QBT_SKIP_CLEANUP): {self.commands['skip_cleanup']}") logger.debug(f" --skip-qb-version-check (QBT_SKIP_QB_VERSION_CHECK): {self.commands['skip_qb_version_check']}") logger.debug(f" --dry-run (QBT_DRY_RUN): {self.commands['dry_run']}") @@ -136,6 +139,9 @@ class Config: self.data["webhooks"] = temp if "bhd" in self.data: self.data["bhd"] = self.data.pop("bhd") + if "share_limits" in self.data: + self.data["share_limits"] = self.data.pop("share_limits") + self.dry_run = self.commands["dry_run"] self.loglevel = "DRYRUN" if self.dry_run else "INFO" self.session = requests.Session() @@ -148,10 +154,14 @@ class Config: self.data, "tracker_error_tag", parent="settings", default="issue" ), "nohardlinks_tag": self.util.check_for_attribute(self.data, "nohardlinks_tag", parent="settings", default="noHL"), + "share_limits_suffix_tag": self.util.check_for_attribute( + self.data, "share_limits_suffix_tag", parent="settings", default="share_limit" + ), } self.tracker_error_tag = self.settings["tracker_error_tag"] self.nohardlinks_tag = self.settings["nohardlinks_tag"] + self.share_limits_suffix_tag = "." + self.settings["share_limits_suffix_tag"] default_ignore_tags = [self.nohardlinks_tag, self.tracker_error_tag, "cross-seed"] self.settings["ignoreTags_OnUpdate"] = self.util.check_for_attribute( @@ -167,6 +177,7 @@ class Config: "tag_tracker_error": None, "rem_orphaned": None, "tag_nohardlinks": None, + "share_limits": None, "cleanup_dirs": None, } @@ -333,7 +344,7 @@ class Config: var_type="int", min_int=-1, do_print=False, - default=0, + default=-1, save=False, ) self.nohardlinks[cat]["resume_torrent_after_untagging_noHL"] = self.util.check_for_attribute( @@ -357,6 +368,153 @@ class Config: self.notify(err, "Config") raise Failed(err) + # share limits + self.share_limits = None + if "share_limits" in self.data and self.commands["share_limits"]: + + def _sort_share_limits(share_limits): + sorted_limits = sorted( + share_limits.items(), key=lambda x: x[1].get("priority", float("inf")) if x[1] is not None else float("inf") + ) + priorities = set() + for key, value in sorted_limits: + if value is None: + value = {} + if "priority" in value: + priority = value["priority"] + if priority in priorities: + err = ( + f"Config Error: Duplicate priority '{priority}' found in share_limits " + f"for the grouping '{key}'. Priority must be a unique value and greater than or equal to 1" + ) + self.notify(err, "Config") + raise Failed(err) + else: + priority = max(priorities) + 1 + logger.warning( + f"Priority not defined for the grouping '{key}' in share_limits. " f"Setting priority to {priority}" + ) + value["priority"] = self.util.check_for_attribute( + self.data, + "priority", + parent="share_limits", + subparent=key, + var_type="float", + default=priority, + save=True, + ) + priorities.add(priority) + return OrderedDict(sorted_limits) + + self.share_limits = OrderedDict() + sorted_share_limits = _sort_share_limits(self.data["share_limits"]) + for group in sorted_share_limits: + self.share_limits[group] = {} + self.share_limits[group]["priority"] = sorted_share_limits[group]["priority"] + self.share_limits[group]["tags"] = self.util.check_for_attribute( + self.data, + "tags", + parent="share_limits", + subparent=group, + var_type="list", + default_is_none=True, + do_print=False, + save=False, + ) + self.share_limits[group]["exclude_tags"] = self.util.check_for_attribute( + self.data, + "exclude_tags", + parent="share_limits", + subparent=group, + var_type="list", + default_is_none=True, + do_print=False, + save=False, + ) + self.share_limits[group]["categories"] = self.util.check_for_attribute( + self.data, + "categories", + parent="share_limits", + subparent=group, + var_type="list", + default_is_none=True, + do_print=False, + save=False, + ) + self.share_limits[group]["cleanup"] = self.util.check_for_attribute( + self.data, "cleanup", parent="share_limits", subparent=group, var_type="bool", default=False, do_print=False + ) + self.share_limits[group]["max_ratio"] = self.util.check_for_attribute( + self.data, + "max_ratio", + parent="share_limits", + subparent=group, + var_type="float", + min_int=-2, + default=-1, + do_print=False, + save=False, + ) + self.share_limits[group]["max_seeding_time"] = self.util.check_for_attribute( + self.data, + "max_seeding_time", + parent="share_limits", + subparent=group, + var_type="int", + min_int=-2, + default=-1, + do_print=False, + save=False, + ) + self.share_limits[group]["min_seeding_time"] = self.util.check_for_attribute( + self.data, + "min_seeding_time", + parent="share_limits", + subparent=group, + var_type="int", + min_int=0, + default=0, + do_print=False, + save=False, + ) + self.share_limits[group]["limit_upload_speed"] = self.util.check_for_attribute( + self.data, + "limit_upload_speed", + parent="share_limits", + subparent=group, + var_type="int", + min_int=-1, + default=0, + do_print=False, + save=False, + ) + self.share_limits[group]["resume_torrent_after_change"] = self.util.check_for_attribute( + self.data, + "resume_torrent_after_change", + parent="share_limits", + subparent=group, + var_type="bool", + default=True, + do_print=False, + save=False, + ) + self.share_limits[group]["add_group_to_tag"] = self.util.check_for_attribute( + self.data, + "add_group_to_tag", + parent="share_limits", + subparent=group, + var_type="bool", + default=True, + do_print=False, + save=False, + ) + self.share_limits[group]["torrents"] = [] + else: + if self.commands["share_limits"]: + err = "Config Error: share_limits. No valid grouping found." + self.notify(err, "Config") + raise Failed(err) + # Add RecycleBin self.recyclebin = {} self.recyclebin["enabled"] = self.util.check_for_attribute( diff --git a/modules/core/share_limits.py b/modules/core/share_limits.py new file mode 100644 index 0000000..2e70ace --- /dev/null +++ b/modules/core/share_limits.py @@ -0,0 +1,399 @@ +import os +from datetime import timedelta + +from modules import util + +logger = util.logger + + +class ShareLimits: + 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 share limits changed + self.stats_deleted = 0 # counter for the number of torrents that \ + # meets the criteria for ratio limit/seed limit for deletion + self.stats_deleted_contents = 0 # counter for the number of torrents that \ + # 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 # root directory of torrents + self.remote_dir = qbit_manager.config.remote_dir # remote directory of torrents + self.share_limits_config = qbit_manager.config.share_limits # configuration of share limits + self.torrents_updated = [] # list of torrents that have been updated + self.torrent_hash_checked = [] # list of torrent hashes that have been checked for share limits + self.share_limits_suffix_tag = qbit_manager.config.share_limits_suffix_tag # suffix tag for share limits + self.group_tag = None # tag for the share limit group + + self.update_share_limits() + + def update_share_limits(self): + """Updates share limits for torrents based on grouping""" + logger.separator("Updating Share Limits based on priority", space=False, border=False) + torrent_list = self.qbt.get_torrents({"status_filter": "completed"}) + self.assign_torrents_to_group(torrent_list) + for group_name, group_config in self.share_limits_config.items(): + torrents = group_config["torrents"] + self.torrents_updated = [] + self.tdel_dict = {} + if torrents: + self.update_share_limits_for_group(group_name, group_config, torrents) + attr = { + "function": "share_limits", + "title": f"Updating Share Limits for {group_name}. Priority {group_config['priority']}", + "body": f"Updated {len(self.torrents_updated)} torrents.", + "grouping": group_name, + "torrent_list": self.torrents_updated, + "torrent_tag": self.group_tag, + "torrent_max_ratio": group_config["max_ratio"], + "torrent_max_seeding_time": group_config["max_seeding_time"], + "torrent_min_seeding_time": group_config["min_seeding_time"], + "torrent_limit_upload_speed": group_config["limit_upload_speed"], + } + self.config.send_notifications(attr) + if group_config["cleanup"] and len(self.tdel_dict) > 0: + self.cleanup_torrents_for_group(group_name, group_config["priority"]) + + def cleanup_torrents_for_group(self, group_name, priority): + """Deletes torrents that have reached the ratio/seed limit""" + logger.separator( + f"Cleaning up torrents that have reached ratio/seed limit for {group_name}. Priority {priority}", + space=False, + border=False, + ) + group_notifications = len(self.tdel_dict) > 10 + t_deleted = set() + t_deleted_and_contents = set() + for torrent_hash, torrent_dict in self.tdel_dict.items(): + torrent = torrent_dict["torrent"] + t_name = torrent.name + 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) == torrent_dict["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(torrent_dict["body"], self.config.loglevel) + body += logger.print_line( + logger.insert_space("Cleanup: True [Meets Share Limits]", 8), + self.config.loglevel, + ) + attr = { + "function": "cleanup_share_limits", + "title": "Share limit removal", + "grouping": group_name, + "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 + t_deleted.add(t_name) + 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 + t_deleted_and_contents.add(t_name) + 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 + t_deleted.add(t_name) + 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) + if not group_notifications: + self.config.send_notifications(attr) + self.qbt.torrentinfo[t_name]["count"] -= 1 + if group_notifications: + if t_deleted: + attr = { + "function": "cleanup_share_limits", + "title": "Share limit removal - Deleted .torrent but NOT content files.", + "body": f"Deleted {self.stats_deleted} .torrents but NOT content files.", + "grouping": group_name, + "torrent_list": list(t_deleted), + "cleanup": True, + "torrents_deleted_and_contents": False, + } + self.config.send_notifications(attr) + if t_deleted_and_contents: + attr = { + "function": "cleanup_share_limits", + "title": "Share limit removal - Deleted .torrent AND content files.", + "body": f"Deleted {self.stats_deleted_contents} .torrents AND content files.", + "grouping": group_name, + "torrent_list": list(t_deleted_and_contents), + "cleanup": True, + "torrents_deleted_and_contents": True, + } + self.config.send_notifications(attr) + + def update_share_limits_for_group(self, group_name, group_config, torrents): + """Updates share limits for torrents in a group""" + logger.separator( + f"Updating Share Limits for [Group {group_name}] [Priority {group_config['priority']}]", space=False, border=False + ) + for torrent in torrents: + t_name = torrent.name + t_hash = torrent.hash + tracker = self.qbt.get_tags(torrent.trackers) + check_max_ratio = group_config["max_ratio"] != torrent.max_ratio + check_max_seeding_time = group_config["max_seeding_time"] != torrent.max_seeding_time + # Treat upload limit as -1 if it is set to 0 (unlimited) + torrent_upload_limit = -1 if torrent.up_limit == 0 else torrent.up_limit + if group_config["limit_upload_speed"] == 0: + group_config["limit_upload_speed"] = -1 + check_limit_upload_speed = group_config["limit_upload_speed"] != torrent_upload_limit + if ( + check_max_ratio or check_max_seeding_time or check_limit_upload_speed + ) and t_hash not in self.torrent_hash_checked: + if "MinSeedTimeNotReached" not in torrent.tags: + self.group_tag = f"{group_name}{self.share_limits_suffix_tag}" if group_config["add_group_to_tag"] else None + logger.print_line(logger.insert_space(f"Torrent Name: {t_name}", 3), self.config.loglevel) + logger.print_line(logger.insert_space(f'Tracker: {tracker["url"]}', 8), self.config.loglevel) + logger.trace(f"Torrent Category: {torrent.category}") + logger.trace(f"Torrent Tags: {torrent.tags}") + logger.trace(f"Grouping: {group_name}") + logger.trace(f"Config Max Ratio vs Torrent Max Ratio:{group_config['max_ratio']} vs {torrent.max_ratio}") + logger.trace( + "Config Max Seeding Time vs Torrent Max Seeding Time: " + f"{group_config['max_seeding_time']} vs {torrent.max_seeding_time}" + ) + logger.trace( + "Config Limit Upload Speed vs Torrent Limit Upload Speed: " + f"{group_config['limit_upload_speed']} vs {torrent.up_limit}" + ) + if self.group_tag: + logger.print_line(logger.insert_space(f"Added Tag: {self.group_tag}", 8), self.config.loglevel) + self.tag_and_update_share_limits_for_torrent(torrent, group_config) + self.stats_tagged += 1 + self.torrents_updated.append(t_name) + # Cleanup torrents if the torrent meets the criteria for deletion and cleanup is enabled + if group_config["cleanup"]: + tor_reached_seed_limit = self.has_reached_seed_limit( + torrent=torrent, + max_ratio=group_config["max_ratio"], + max_seeding_time=group_config["max_seeding_time"], + min_seeding_time=group_config["min_seeding_time"], + resume_torrent=group_config["resume_torrent_after_change"], + tracker=tracker["url"], + ) + if tor_reached_seed_limit: + if t_hash not in self.tdel_dict: + self.tdel_dict[t_hash] = {} + self.tdel_dict[t_hash]["torrent"] = torrent + self.tdel_dict[t_hash]["content_path"] = torrent["content_path"].replace(self.root_dir, self.remote_dir) + self.tdel_dict[t_hash]["body"] = tor_reached_seed_limit + else: + self.share_limits_config[group_name]["torrents"].remove(torrent) + self.torrent_hash_checked.append(t_hash) + + def tag_and_update_share_limits_for_torrent(self, torrent, group_config): + """Removes previous share limits tag, updates tag and share limits for a torrent, and resumes the torrent""" + # Remove previous share_limits tag + tags = util.get_list(torrent.tags) + for tag in tags: + if self.share_limits_suffix_tag in tag: + tags.remove(tag) + + # Will tag the torrent with the group name if add_group_to_tag is True and set the share limits + self.set_tags_and_limits( + torrent=torrent, + max_ratio=group_config["max_ratio"], + max_seeding_time=group_config["max_seeding_time"], + limit_upload_speed=group_config["limit_upload_speed"], + tags=self.group_tag, + ) + # Resume torrent if it was paused now that the share limit has changed + if torrent.state_enum.is_complete and group_config["resume_torrent_after_change"]: + if not self.config.dry_run: + torrent.resume() + + def assign_torrents_to_group(self, torrent_list): + """Assign torrents to a share limit group based on its tags and category""" + logger.info("Assigning torrents to share limit groups...") + for torrent in torrent_list: + tags = util.get_list(torrent.tags) + category = torrent.category or "" + grouping = self.get_share_limit_group(tags, category) + if grouping: + self.share_limits_config[grouping]["torrents"].append(torrent) + + def get_share_limit_group(self, tags, category): + """Get the share limit group based on the tags and category of the torrent""" + for group_name, group_config in self.share_limits_config.items(): + check_tags = self.check_tags(tags, group_config["tags"], group_config["exclude_tags"]) + check_category = self.check_category(category, group_config["categories"]) + + if check_tags and check_category: + return group_name + return None + + def check_tags(self, tags, include_tags, exclude_tags): + """Check if the torrent has the required tags and does not have the excluded tags""" + if include_tags: + if not set(include_tags).issubset(tags): + return False + if exclude_tags: + if set(exclude_tags).intersection(tags): + return False + return True + + def check_category(self, category, categories): + """Check if the torrent has the required category""" + if categories: + if category not in categories: + return False + return True + + def set_tags_and_limits( + self, torrent, max_ratio, max_seeding_time, limit_upload_speed=None, tags=None, restore=False, do_print=True + ): + """Set tags and limits for a torrent""" + body = [] + if limit_upload_speed: + if limit_upload_speed != -1: + msg = logger.insert_space(f"Limit UL Speed: {limit_upload_speed} kB/s", 1) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + if max_ratio or max_seeding_time: + if (max_ratio == -2 and max_seeding_time == -2) and not restore: + msg = logger.insert_space("Share Limit: Use Global Share Limit", 4) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + elif (max_ratio == -1 and max_seeding_time == -1) and not restore: + msg = logger.insert_space("Share Limit: Set No Share Limit", 4) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + else: + if max_ratio != torrent.max_ratio and (not max_seeding_time or max_seeding_time < 0): + msg = logger.insert_space(f"Share Limit: Max Ratio = {max_ratio}", 4) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + elif max_seeding_time != torrent.max_seeding_time and (not max_ratio or max_ratio < 0): + msg = logger.insert_space(f"Share Limit: Max Seed Time = {max_seeding_time} min", 4) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + elif max_ratio != torrent.max_ratio or max_seeding_time != torrent.max_seeding_time: + msg = logger.insert_space(f"Share Limit: Max Ratio = {max_ratio}, Max Seed Time = {max_seeding_time} min", 4) + if do_print: + body += logger.print_line(msg, self.config.loglevel) + else: + body.append(msg) + # Update Torrents + if not self.config.dry_run: + if tags and tags not in torrent.tags: + torrent.add_tags(tags) + if limit_upload_speed: + if limit_upload_speed == -1: + torrent.set_upload_limit(-1) + else: + torrent.set_upload_limit(limit_upload_speed * 1024) + if not max_ratio: + max_ratio = torrent.max_ratio + if not max_seeding_time: + max_seeding_time = torrent.max_seeding_time + if "MinSeedTimeNotReached" in torrent.tags: + return [] + torrent.set_share_limits(max_ratio, max_seeding_time) + return body + + def has_reached_seed_limit(self, torrent, max_ratio, max_seeding_time, min_seeding_time, resume_torrent, tracker): + """Check if torrent has reached seed limit""" + body = "" + + def _has_reached_min_seeding_time_limit(): + print_log = [] + if torrent.seeding_time >= min_seeding_time * 60: + if "MinSeedTimeNotReached" in torrent.tags: + torrent.remove_tags(tags="MinSeedTimeNotReached") + return True + else: + if "MinSeedTimeNotReached" not in torrent.tags: + print_log += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) + print_log += logger.print_line(logger.insert_space(f"Tracker: {tracker}", 8), self.config.loglevel) + print_log += logger.print_line( + logger.insert_space( + f"Min seed time not met: {timedelta(seconds=torrent.seeding_time)} <= " + f"{timedelta(minutes=min_seeding_time)}. Removing Share Limits so qBittorrent can continue seeding.", + 8, + ), + self.config.loglevel, + ) + print_log += logger.print_line( + logger.insert_space("Adding Tag: MinSeedTimeNotReached", 8), self.config.loglevel + ) + if not self.config.dry_run: + torrent.add_tags("MinSeedTimeNotReached") + torrent.set_share_limits(-1, -1) + if resume_torrent: + torrent.resume() + return False + + def _has_reached_seeding_time_limit(): + nonlocal body + seeding_time_limit = None + if not max_seeding_time: + return False + if max_seeding_time >= 0: + seeding_time_limit = max_seeding_time + elif max_seeding_time == -2 and self.global_max_seeding_time_enabled: + seeding_time_limit = self.global_max_seeding_time + else: + return False + if seeding_time_limit: + if (torrent.seeding_time >= seeding_time_limit * 60) and _has_reached_min_seeding_time_limit(): + body += logger.insert_space( + f"Seeding Time vs Max Seed Time: {timedelta(seconds=torrent.seeding_time)} >= " + f"{timedelta(minutes=seeding_time_limit)}", + 8, + ) + return True + return False + + if max_ratio: + if max_ratio >= 0: + if torrent.ratio >= max_ratio and _has_reached_min_seeding_time_limit(): + body += logger.insert_space(f"Ratio vs Max Ratio: {torrent.ratio:.2f} >= {max_ratio:.2f}", 8) + return body + elif max_ratio == -2 and self.global_max_ratio_enabled and _has_reached_min_seeding_time_limit(): + if torrent.ratio >= self.global_max_ratio: + body += logger.insert_space( + f"Ratio vs Global Max Ratio: {torrent.ratio:.2f} >= {self.global_max_ratio:.2f}", 8 + ) + return body + if _has_reached_seeding_time_limit(): + return body + return False diff --git a/modules/core/tag_nohardlinks.py b/modules/core/tag_nohardlinks.py index 97d4495..317c1eb 100644 --- a/modules/core/tag_nohardlinks.py +++ b/modules/core/tag_nohardlinks.py @@ -1,5 +1,3 @@ -import os - from modules import util logger = util.logger @@ -12,12 +10,7 @@ class TagNoHardLinks: 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 @@ -25,110 +18,29 @@ class TagNoHardLinks: self.tag_nohardlinks() - def add_tag_no_hl(self, torrent, tracker, category, max_ratio, max_seeding_time, add_tag=True): + def add_tag_no_hl(self, torrent, tracker, category): """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"Added Tag: {self.nohardlinks_tag}", 6)) + title = "Tagging 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 + if not self.config.dry_run: + torrent.add_tags(self.nohardlinks_tag) + self.stats_tagged += 1 + 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"], + } + self.config.send_notifications(attr) def check_previous_nohardlinks_tagged_torrents(self, has_nohardlinks, torrent, tracker, category): """ @@ -145,28 +57,8 @@ class TagNoHardLinks: ) 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", @@ -176,9 +68,6 @@ class TagNoHardLinks: "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) @@ -211,120 +100,17 @@ class TagNoHardLinks: # 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"{'Did not Tag' if self.config.dry_run else 'Added Tag'} for {self.stats_tagged} " f".torrent{'s.' if self.stats_tagged > 1 else '.'}", self.config.loglevel, ) @@ -333,19 +119,7 @@ class TagNoHardLinks: 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"{self.nohardlinks_tag} tags 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 index 4f67cf1..029b9be 100644 --- a/modules/core/tags.py +++ b/modules/core/tags.py @@ -9,6 +9,7 @@ class Tags: self.config = qbit_manager.config self.client = qbit_manager.client self.stats = 0 + self.share_limits_suffix_tag = qbit_manager.config.share_limits_suffix_tag # suffix tag for share limits self.tags() @@ -17,7 +18,8 @@ class Tags: 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) + check_tags = [tag for tag in util.get_list(torrent.tags) if self.share_limits_suffix_tag not in tag] + 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"]: @@ -29,15 +31,8 @@ class Tags: 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"], - ) - ) + if not self.config.dry_run: + torrent.add_tags(tracker["tag"]) category = self.qbt.get_category(torrent.save_path) if torrent.category == "" else torrent.category attr = { "function": "tag_update", @@ -48,9 +43,6 @@ class Tags: "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: diff --git a/modules/qbittorrent.py b/modules/qbittorrent.py index 7344f89..37d3385 100755 --- a/modules/qbittorrent.py +++ b/modules/qbittorrent.py @@ -1,7 +1,6 @@ """Qbittorrent Module""" import os import sys -from datetime import timedelta from qbittorrentapi import APIConnectionError from qbittorrentapi import Client @@ -25,7 +24,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"] + TORRENT_DICT_COMMANDS = ["recheck", "cross_seed", "rem_unregistered", "tag_tracker_error", "tag_nohardlinks", "share_limits"] def __init__(self, config, params): self.config = config @@ -202,142 +201,11 @@ class Qbt: """Get torrents from qBittorrent""" return self.client.torrents.info(**params) - def set_tags_and_limits( - self, torrent, max_ratio, max_seeding_time, limit_upload_speed=None, tags=None, restore=False, do_print=True - ): - """Set tags and limits for a torrent""" - body = [] - if limit_upload_speed: - if limit_upload_speed != -1: - msg = logger.insert_space(f"Limit UL Speed: {limit_upload_speed} kB/s", 1) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - if max_ratio or max_seeding_time: - if (max_ratio == -2 and max_seeding_time == -2) and not restore: - msg = logger.insert_space("Share Limit: Use Global Share Limit", 4) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - elif (max_ratio == -1 and max_seeding_time == -1) and not restore: - msg = logger.insert_space("Share Limit: Set No Share Limit", 4) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - else: - if max_ratio != torrent.max_ratio and (not max_seeding_time or max_seeding_time < 0): - msg = logger.insert_space(f"Share Limit: Max Ratio = {max_ratio}", 4) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - elif max_seeding_time != torrent.max_seeding_time and (not max_ratio or max_ratio < 0): - msg = logger.insert_space(f"Share Limit: Max Seed Time = {max_seeding_time} min", 4) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - elif max_ratio != torrent.max_ratio or max_seeding_time != torrent.max_seeding_time: - msg = logger.insert_space(f"Share Limit: Max Ratio = {max_ratio}, Max Seed Time = {max_seeding_time} min", 4) - if do_print: - body += logger.print_line(msg, self.config.loglevel) - else: - body.append(msg) - # Update Torrents - if not self.config.dry_run: - if tags: - torrent.add_tags(tags) - if limit_upload_speed: - if limit_upload_speed == -1: - torrent.set_upload_limit(-1) - else: - torrent.set_upload_limit(limit_upload_speed * 1024) - if not max_ratio: - max_ratio = torrent.max_ratio - if not max_seeding_time: - max_seeding_time = torrent.max_seeding_time - if "MinSeedTimeNotReached" in torrent.tags: - return [] - torrent.set_share_limits(max_ratio, max_seeding_time) - return body - - def has_reached_seed_limit(self, torrent, max_ratio, max_seeding_time, min_seeding_time, resume_torrent, tracker): - """Check if torrent has reached seed limit""" - body = "" - - def _has_reached_min_seeding_time_limit(): - print_log = [] - if torrent.seeding_time >= min_seeding_time * 60: - if "MinSeedTimeNotReached" in torrent.tags: - torrent.remove_tags(tags="MinSeedTimeNotReached") - return True - else: - print_log += logger.print_line(logger.insert_space(f"Torrent Name: {torrent.name}", 3), self.config.loglevel) - print_log += logger.print_line(logger.insert_space(f"Tracker: {tracker}", 8), self.config.loglevel) - print_log += logger.print_line( - logger.insert_space( - f"Min seed time not met: {timedelta(seconds=torrent.seeding_time)} <= " - f"{timedelta(minutes=min_seeding_time)}. Removing Share Limits so qBittorrent can continue seeding.", - 8, - ), - self.config.loglevel, - ) - print_log += logger.print_line(logger.insert_space("Adding Tag: MinSeedTimeNotReached", 8), self.config.loglevel) - if not self.config.dry_run: - torrent.add_tags("MinSeedTimeNotReached") - torrent.set_share_limits(-1, -1) - if resume_torrent: - torrent.resume() - return False - - def _has_reached_seeding_time_limit(): - nonlocal body - seeding_time_limit = None - if not max_seeding_time: - return False - if max_seeding_time >= 0: - seeding_time_limit = max_seeding_time - elif max_seeding_time == -2 and self.global_max_seeding_time_enabled: - seeding_time_limit = self.global_max_seeding_time - else: - return False - if seeding_time_limit: - if (torrent.seeding_time >= seeding_time_limit * 60) and _has_reached_min_seeding_time_limit(): - body += logger.insert_space( - f"Seeding Time vs Max Seed Time: {timedelta(seconds=torrent.seeding_time)} >= " - f"{timedelta(minutes=seeding_time_limit)}", - 8, - ) - return True - return False - - if max_ratio: - if max_ratio >= 0: - if torrent.ratio >= max_ratio and _has_reached_min_seeding_time_limit(): - body += logger.insert_space(f"Ratio vs Max Ratio: {torrent.ratio:.2f} >= {max_ratio:.2f}", 8) - return body - elif max_ratio == -2 and self.global_max_ratio_enabled and _has_reached_min_seeding_time_limit(): - if torrent.ratio >= self.global_max_ratio: - body += logger.insert_space( - f"Ratio vs Global Max Ratio: {torrent.ratio:.2f} >= {self.global_max_ratio:.2f}", 8 - ) - return body - if _has_reached_seeding_time_limit(): - return body - return False - 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( @@ -376,76 +244,6 @@ class Qbt: 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, - ) - 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, - ) - 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, - ) - 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", @@ -458,50 +256,6 @@ class Qbt: 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, - ) - 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", diff --git a/qbit_manage.py b/qbit_manage.py index 851a66a..aa77768 100755 --- a/qbit_manage.py +++ b/qbit_manage.py @@ -141,6 +141,16 @@ parser.add_argument( "When files get upgraded they no longer become linked with your media therefore will be tagged with a new tag noHL. " "You can then safely delete/remove these torrents to free up any extra space that is not being used by your media folder.", ) +parser.add_argument( + "-sl", + "--share-limits", + dest="share_limits", + action="store_true", + default=False, + help="Use this to help apply and manage your torrent share limits based on your tags/categories." + "This can apply a max ratio, seed time limits to your torrents or limit your torrent upload speed as well." + "Share limits are applied in the order of priority specified.", +) parser.add_argument( "-sc", "--skip-cleanup", @@ -237,6 +247,7 @@ rem_unregistered = get_arg("QBT_REM_UNREGISTERED", args.rem_unregistered, arg_bo tag_tracker_error = get_arg("QBT_TAG_TRACKER_ERROR", args.tag_tracker_error, arg_bool=True) rem_orphaned = get_arg("QBT_REM_ORPHANED", args.rem_orphaned, arg_bool=True) tag_nohardlinks = get_arg("QBT_TAG_NOHARDLINKS", args.tag_nohardlinks, arg_bool=True) +share_limits = get_arg("QBT_SHARE_LIMITS", args.share_limits, arg_bool=True) skip_cleanup = get_arg("QBT_SKIP_CLEANUP", args.skip_cleanup, arg_bool=True) skip_qb_version_check = get_arg("QBT_SKIP_QB_VERSION_CHECK", args.skip_qb_version_check, arg_bool=True) dry_run = get_arg("QBT_DRY_RUN", args.dry_run, arg_bool=True) @@ -285,6 +296,7 @@ for v in [ "tag_tracker_error", "rem_orphaned", "tag_nohardlinks", + "share_limits", "skip_cleanup", "skip_qb_version_check", "dry_run", @@ -329,6 +341,7 @@ 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 +from modules.core.share_limits import ShareLimits # noqa def my_except_hook(exctype, value, tbi): @@ -458,8 +471,13 @@ def start(): 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 + + # Set Share Limits + if cfg.commands["share_limits"]: + share_limits = ShareLimits(qbit_manager) + stats["tagged"] += share_limits.stats_tagged + stats["deleted"] += share_limits.stats_deleted + stats["deleted_contents"] += share_limits.stats_deleted_contents # Remove Orphaned Files if cfg.commands["rem_orphaned"]: @@ -583,6 +601,7 @@ if __name__ == "__main__": logger.debug(f" --tag-tracker-error (QBT_TAG_TRACKER_ERROR): {tag_tracker_error}") logger.debug(f" --rem-orphaned (QBT_REM_ORPHANED): {rem_orphaned}") logger.debug(f" --tag-nohardlinks (QBT_TAG_NOHARDLINKS): {tag_nohardlinks}") + logger.debug(f" --share-limits (QBT_SHARE_LIMITS): {share_limits}") logger.debug(f" --skip-cleanup (QBT_SKIP_CLEANUP): {skip_cleanup}") logger.debug(f" --skip-qb-version-check (QBT_SKIP_QB_VERSION_CHECK): {skip_qb_version_check}") logger.debug(f" --dry-run (QBT_DRY_RUN): {dry_run}")