From 21c1d5a07bfd6105ae31a58a2774faf1b0f2fd36 Mon Sep 17 00:00:00 2001 From: bobokun Date: Fri, 31 Dec 2021 19:28:45 -0500 Subject: [PATCH] [FR] Save .torrents in Recycle Bin #73 Save the .torrent and .fastresume files into Recycle Bin before deleting --- config/config.yml.sample | 16 ++++++-- modules/config.py | 9 ++++ modules/qbittorrent.py | 89 +++++++++++++++++++++++++++++----------- modules/util.py | 27 +++++++++++- 4 files changed, 112 insertions(+), 29 deletions(-) diff --git a/config/config.yml.sample b/config/config.yml.sample index 139610a..007a438 100644 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -1,5 +1,7 @@ # This is an example configuration file that documents all the options. # It will need to be modified for your specific use case. +# Please refer to the link below for more details on how to set up the configuration file +# https://github.com/StuffAnThings/qbit_manage/wiki/Config-Setup # qBittorrent parameters qbt: @@ -17,10 +19,13 @@ directory: # remote_dir var: # Path of docker host mapping of root_dir. # Must be set if you're running qbit_manage locally and qBittorrent/cross_seed is in a docker # recycle_bin var: # Path of the RecycleBin folder. Default location is set to remote_dir/.RecycleBin + # torrents_dir var: # Path of the your qbittorrent torrents directory. Required for `save_torrents` attribute in recyclebin + cross_seed: "/your/path/here/" root_dir: "/data/torrents/" remote_dir: "/mnt/user/data/torrents/" recycle_bin: "/mnt/user/data/torrents/.RecycleBin" + torrents_dir: "/qbittorrent/data/BT_backup" # Category & Path Parameters cat: @@ -125,10 +130,15 @@ nohardlinks: # By default the Recycle Bin will be emptied on every run of the qbit_manage script if empty_after_x_days is defined. recyclebin: enabled: true - # empty_after_x_days var: Will automatically remove all files and folders in recycle bin after x days. (Checks every script run) - # If this variable is not defined it, the RecycleBin will never be emptied. - # WARNING: Setting this variable to 0 will delete all files immediately upon script run! + # empty_after_x_days var: + # Will automatically remove all files and folders in recycle bin after x days. (Checks every script run) + # If this variable is not defined it, the RecycleBin will never be emptied. + # WARNING: Setting this variable to 0 will delete all files immediately upon script run! empty_after_x_days: 60 + # save_torrents var: + # If this option is set to true you MUST fill out the torrents_dir in the directory attribute. + # This will save a copy of your .torrent and .fastresume file in the recycle bin before deleting it from qbittorrent + save_torrents: true # Orphaned files are those in the root_dir download directory that are not referenced by any active torrents. orphaned: diff --git a/modules/config.py b/modules/config.py index 18d232f..c53a4c4 100644 --- a/modules/config.py +++ b/modules/config.py @@ -176,6 +176,7 @@ class Config: self.recyclebin = {} self.recyclebin['enabled'] = self.util.check_for_attribute(self.data, "enabled", parent="recyclebin", var_type="bool", default=True) self.recyclebin['empty_after_x_days'] = self.util.check_for_attribute(self.data, "empty_after_x_days", parent="recyclebin", var_type="int", default_is_none=True) + self.recyclebin['save_torrents'] = self.util.check_for_attribute(self.data, "save_torrents", parent="recyclebin", var_type="bool", default=False) # Add Orphaned self.orphaned = {} @@ -195,6 +196,14 @@ class Config: else: self.cross_seed_dir = self.util.check_for_attribute(self.data, "cross_seed", parent="directory", default_is_none=True) self.recycle_dir = self.util.check_for_attribute(self.data, "recycle_bin", parent="directory", var_type="path", default=os.path.join(self.remote_dir, '.RecycleBin')) + if self.recyclebin['enabled'] and self.recyclebin['save_torrents']: + self.torrents_dir = self.util.check_for_attribute(self.data, "torrents_dir", parent="directory", var_type="path") + if not any(File.endswith(".torrent") for File in os.listdir(self.torrents_dir)): + e = f"Config Error: The location {self.torrents_dir} does not contain any .torrents" + self.notify(e, 'Config') + raise Failed(e) + else: + self.torrents_dir = self.util.check_for_attribute(self.data, "torrents_dir", parent="directory", default_is_none=True) else: e = "Config Error: directory attribute not found" self.notify(e, 'Config') diff --git a/modules/qbittorrent.py b/modules/qbittorrent.py index 0b03a55..13d8390 100644 --- a/modules/qbittorrent.py +++ b/modules/qbittorrent.py @@ -335,14 +335,14 @@ class Qbt: "notifiarr_indexer": tracker["notifiarr"], } if (os.path.exists(torrent['content_path'].replace(root_dir, root_dir))): - if not dry_run: self.tor_delete_recycle(torrent) del_tor_cont += 1 attr["torrents_deleted_and_contents"] = True + if not dry_run: self.tor_delete_recycle(torrent, attr) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel) else: - if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False) del_tor += 1 attr["torrents_deleted_and_contents"] = False + if not dry_run: self.tor_delete_recycle(torrent, attr) body += print_line(util.insert_space('Deleted .torrent but NOT content files.', 8), loglevel) attr["body"] = "\n".join(body) self.config.send_notifications(attr) @@ -381,18 +381,18 @@ class Qbt: if t_count > 1: # Checks if any of the original torrents are working if '' in t_msg or 2 in t_status: - if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False) attr["torrents_deleted_and_contents"] = False + if not dry_run: self.tor_delete_recycle(torrent, attr) body += print_line(util.insert_space('Deleted .torrent but NOT content files.', 8), loglevel) del_tor += 1 else: - if not dry_run: self.tor_delete_recycle(torrent) attr["torrents_deleted_and_contents"] = True + if not dry_run: self.tor_delete_recycle(torrent, attr) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel) del_tor_cont += 1 else: - if not dry_run: self.tor_delete_recycle(torrent) attr["torrents_deleted_and_contents"] = True + if not dry_run: self.tor_delete_recycle(torrent, attr) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel) del_tor_cont += 1 attr["body"] = "\n".join(body) @@ -706,10 +706,11 @@ class Qbt: print_line("No Orphaned Files found.", loglevel) return orphaned - def tor_delete_recycle(self, torrent): + def tor_delete_recycle(self, torrent, info): if self.config.recyclebin['enabled']: tor_files = [] try: + info_hash = torrent.hash # Define torrent files/folders for file in torrent.files: tor_files.append(os.path.join(torrent.save_path, file.name)) @@ -717,25 +718,63 @@ class Qbt: return # Create recycle bin if not exists recycle_path = self.config.recycle_dir + torrent_path = os.path.join(recycle_path, 'torrents') + torrents_json_path = os.path.join(recycle_path, 'torrents_json') + os.makedirs(recycle_path, exist_ok=True) + if self.config.recyclebin['save_torrents']: + if os.path.isdir(torrent_path) is False: os.makedirs(torrent_path) + if os.path.isdir(torrents_json_path) is False: os.makedirs(torrents_json_path) + torrent_json_file = os.path.join(torrents_json_path, f"{info['torrent_name']}.json") + torrent_json = util.load_json(torrent_json_file) + if not torrent_json: + torrent_json["torrent_name"] = info["torrent_name"] + torrent_json["category"] = info["torrent_category"] + dot_torrent_files = [] + for File in os.listdir(self.config.torrents_dir): + if File.startswith(info_hash): + dot_torrent_files.append(File) + try: + util.copy_files(os.path.join(self.config.torrents_dir, File), os.path.join(torrent_path, File)) + except Exception as e: + util.print_stacktrace() + self.config.notify(e, 'Deleting Torrent', False) + logger.warning(f"RecycleBin Warning: {e}") + if "tracker_torrent_files" in torrent_json: + tracker_torrent_files = torrent_json["tracker_torrent_files"] + else: + tracker_torrent_files = {} + tracker_torrent_files[info["torrent_tracker"]] = dot_torrent_files + torrent_json["tracker_torrent_files"] = tracker_torrent_files + if "files" not in torrent_json: + files_cleaned = [f.replace(self.config.root_dir, '') for f in tor_files] + torrent_json["files"] = files_cleaned + torrent_json["deleted_contents"] = info['torrents_deleted_and_contents'] + util.save_json(torrent_json, torrent_json_file) + if info['torrents_deleted_and_contents'] is True: + separator(f"Moving {len(tor_files)} files to RecycleBin", space=False, border=False, loglevel='DEBUG') + if len(tor_files) == 1: print_line(tor_files[0], 'DEBUG') + else: print_multiline("\n".join(tor_files), 'DEBUG') + logger.debug(f'Moved {len(tor_files)} files to {recycle_path.replace(self.config.remote_dir,self.config.root_dir)}') - separator(f"Moving {len(tor_files)} files to RecycleBin", space=False, border=False, loglevel='DEBUG') - if len(tor_files) == 1: print_line(tor_files[0], 'DEBUG') - else: print_multiline("\n".join(tor_files), 'DEBUG') - logger.debug(f'Moved {len(tor_files)} files to {recycle_path.replace(self.config.remote_dir,self.config.root_dir)}') - - # Move files from torrent contents to Recycle bin - for file in tor_files: - src = file.replace(self.config.root_dir, self.config.remote_dir) - dest = os.path.join(recycle_path, file.replace(self.config.root_dir, '')) - # Move files and change date modified - try: - util.move_files(src, dest, True) - except FileNotFoundError: - print_line(f'RecycleBin Warning - FileNotFound: No such file or directory: {src} ', 'WARNING') - # Delete torrent and files - torrent.delete(hash=torrent.hash, delete_files=False) - # Remove any empty directories - util.remove_empty_directories(torrent.save_path.replace(self.config.root_dir, self.config.remote_dir), "**/*") + # Move files from torrent contents to Recycle bin + for file in tor_files: + src = file.replace(self.config.root_dir, self.config.remote_dir) + dest = os.path.join(recycle_path, file.replace(self.config.root_dir, '')) + # Move files and change date modified + try: + util.move_files(src, dest, True) + except FileNotFoundError: + e = print_line(f'RecycleBin Warning - FileNotFound: No such file or directory: {src} ', 'WARNING') + self.config.notify(e, 'Deleting Torrent', False) + # Delete torrent and files + torrent.delete(delete_files=False) + # Remove any empty directories + util.remove_empty_directories(torrent.save_path.replace(self.config.root_dir, self.config.remote_dir), "**/*") + else: + torrent.delete(delete_files=False) else: - torrent.delete(hash=torrent.hash, delete_files=True) + if info['torrents_deleted_and_contents'] is True: + torrent.delete(delete_files=True) + else: + torrent.delete(delete_files=False) diff --git a/modules/util.py b/modules/util.py index a062bf9..81a47d7 100644 --- a/modules/util.py +++ b/modules/util.py @@ -1,4 +1,4 @@ -import logging, os, shutil, traceback, time, signal +import logging, os, shutil, traceback, time, signal, json from logging.handlers import RotatingFileHandler from ruamel import yaml from pathlib import Path @@ -297,6 +297,14 @@ def move_files(src, dest, mod=False): os.utime(dest, (modTime, modTime)) +# Copy Files from source to destination +def copy_files(src, dest): + dest_path = os.path.dirname(dest) + if os.path.isdir(dest_path) is False: + os.makedirs(dest_path) + shutil.copyfile(src, dest) + + # Remove any empty directories after moving files def remove_empty_directories(pathlib_root_dir, pattern): pathlib_root_dir = Path(pathlib_root_dir) @@ -328,6 +336,23 @@ def nohardlink(file): return check +# Load json file if exists +def load_json(file): + if (os.path.isfile(file)): + f = open(file, "r") + data = json.load(f) + f.close() + else: + data = {} + return data + + +# Save json file overwrite if exists +def save_json(torrent_json, dest): + with open(dest, 'w', encoding='utf-8') as f: + json.dump(torrent_json, f, ensure_ascii=False, indent=4) + + # Gracefully kill script when docker stops class GracefulKiller: kill_now = False