[FR] Save .torrents in Recycle Bin #73

Save the .torrent and .fastresume files into Recycle Bin before deleting
This commit is contained in:
bobokun 2021-12-31 19:28:45 -05:00
parent 7bba88d9fc
commit 21c1d5a07b
No known key found for this signature in database
GPG key ID: 9665BA6CF5DC2671
4 changed files with 112 additions and 29 deletions

View file

@ -1,5 +1,7 @@
# This is an example configuration file that documents all the options. # This is an example configuration file that documents all the options.
# It will need to be modified for your specific use case. # 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 # qBittorrent parameters
qbt: qbt:
@ -17,10 +19,13 @@ directory:
# <OPTIONAL> remote_dir var: </your/path/here/> # Path of docker host mapping of root_dir. # <OPTIONAL> remote_dir var: </your/path/here/> # 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 # Must be set if you're running qbit_manage locally and qBittorrent/cross_seed is in a docker
# <OPTIONAL> recycle_bin var: </your/path/here/> # Path of the RecycleBin folder. Default location is set to remote_dir/.RecycleBin # <OPTIONAL> recycle_bin var: </your/path/here/> # Path of the RecycleBin folder. Default location is set to remote_dir/.RecycleBin
# <OPTIONAL> torrents_dir var: </your/path/here/> # Path of the your qbittorrent torrents directory. Required for `save_torrents` attribute in recyclebin
cross_seed: "/your/path/here/" cross_seed: "/your/path/here/"
root_dir: "/data/torrents/" root_dir: "/data/torrents/"
remote_dir: "/mnt/user/data/torrents/" remote_dir: "/mnt/user/data/torrents/"
recycle_bin: "/mnt/user/data/torrents/.RecycleBin" recycle_bin: "/mnt/user/data/torrents/.RecycleBin"
torrents_dir: "/qbittorrent/data/BT_backup"
# Category & Path Parameters # Category & Path Parameters
cat: 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. # By default the Recycle Bin will be emptied on every run of the qbit_manage script if empty_after_x_days is defined.
recyclebin: recyclebin:
enabled: true enabled: true
# <OPTIONAL> empty_after_x_days var: Will automatically remove all files and folders in recycle bin after x days. (Checks every script run) # <OPTIONAL> empty_after_x_days var:
# If this variable is not defined it, the RecycleBin will never be emptied. # Will automatically remove all files and folders in recycle bin after x days. (Checks every script run)
# WARNING: Setting this variable to 0 will delete all files immediately upon 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 empty_after_x_days: 60
# <OPTIONAL> 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 files are those in the root_dir download directory that are not referenced by any active torrents.
orphaned: orphaned:

View file

@ -176,6 +176,7 @@ class Config:
self.recyclebin = {} self.recyclebin = {}
self.recyclebin['enabled'] = self.util.check_for_attribute(self.data, "enabled", parent="recyclebin", var_type="bool", default=True) 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['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 # Add Orphaned
self.orphaned = {} self.orphaned = {}
@ -195,6 +196,14 @@ class Config:
else: else:
self.cross_seed_dir = self.util.check_for_attribute(self.data, "cross_seed", parent="directory", default_is_none=True) 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')) 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: else:
e = "Config Error: directory attribute not found" e = "Config Error: directory attribute not found"
self.notify(e, 'Config') self.notify(e, 'Config')

View file

@ -335,14 +335,14 @@ class Qbt:
"notifiarr_indexer": tracker["notifiarr"], "notifiarr_indexer": tracker["notifiarr"],
} }
if (os.path.exists(torrent['content_path'].replace(root_dir, root_dir))): 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 del_tor_cont += 1
attr["torrents_deleted_and_contents"] = True 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) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel)
else: else:
if not dry_run: torrent.delete(hash=torrent.hash, delete_files=False)
del_tor += 1 del_tor += 1
attr["torrents_deleted_and_contents"] = 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) body += print_line(util.insert_space('Deleted .torrent but NOT content files.', 8), loglevel)
attr["body"] = "\n".join(body) attr["body"] = "\n".join(body)
self.config.send_notifications(attr) self.config.send_notifications(attr)
@ -381,18 +381,18 @@ class Qbt:
if t_count > 1: if t_count > 1:
# Checks if any of the original torrents are working # Checks if any of the original torrents are working
if '' in t_msg or 2 in t_status: 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 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) body += print_line(util.insert_space('Deleted .torrent but NOT content files.', 8), loglevel)
del_tor += 1 del_tor += 1
else: else:
if not dry_run: self.tor_delete_recycle(torrent)
attr["torrents_deleted_and_contents"] = True 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) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel)
del_tor_cont += 1 del_tor_cont += 1
else: else:
if not dry_run: self.tor_delete_recycle(torrent)
attr["torrents_deleted_and_contents"] = True 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) body += print_line(util.insert_space('Deleted .torrent AND content files.', 8), loglevel)
del_tor_cont += 1 del_tor_cont += 1
attr["body"] = "\n".join(body) attr["body"] = "\n".join(body)
@ -706,10 +706,11 @@ class Qbt:
print_line("No Orphaned Files found.", loglevel) print_line("No Orphaned Files found.", loglevel)
return orphaned return orphaned
def tor_delete_recycle(self, torrent): def tor_delete_recycle(self, torrent, info):
if self.config.recyclebin['enabled']: if self.config.recyclebin['enabled']:
tor_files = [] tor_files = []
try: try:
info_hash = torrent.hash
# Define torrent files/folders # Define torrent files/folders
for file in torrent.files: for file in torrent.files:
tor_files.append(os.path.join(torrent.save_path, file.name)) tor_files.append(os.path.join(torrent.save_path, file.name))
@ -717,25 +718,63 @@ class Qbt:
return return
# Create recycle bin if not exists # Create recycle bin if not exists
recycle_path = self.config.recycle_dir 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) 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') # Move files from torrent contents to Recycle bin
if len(tor_files) == 1: print_line(tor_files[0], 'DEBUG') for file in tor_files:
else: print_multiline("\n".join(tor_files), 'DEBUG') src = file.replace(self.config.root_dir, self.config.remote_dir)
logger.debug(f'Moved {len(tor_files)} files to {recycle_path.replace(self.config.remote_dir,self.config.root_dir)}') dest = os.path.join(recycle_path, file.replace(self.config.root_dir, ''))
# Move files and change date modified
# Move files from torrent contents to Recycle bin try:
for file in tor_files: util.move_files(src, dest, True)
src = file.replace(self.config.root_dir, self.config.remote_dir) except FileNotFoundError:
dest = os.path.join(recycle_path, file.replace(self.config.root_dir, '')) e = print_line(f'RecycleBin Warning - FileNotFound: No such file or directory: {src} ', 'WARNING')
# Move files and change date modified self.config.notify(e, 'Deleting Torrent', False)
try: # Delete torrent and files
util.move_files(src, dest, True) torrent.delete(delete_files=False)
except FileNotFoundError: # Remove any empty directories
print_line(f'RecycleBin Warning - FileNotFound: No such file or directory: {src} ', 'WARNING') util.remove_empty_directories(torrent.save_path.replace(self.config.root_dir, self.config.remote_dir), "**/*")
# Delete torrent and files else:
torrent.delete(hash=torrent.hash, delete_files=False) 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: 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)

View file

@ -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 logging.handlers import RotatingFileHandler
from ruamel import yaml from ruamel import yaml
from pathlib import Path from pathlib import Path
@ -297,6 +297,14 @@ def move_files(src, dest, mod=False):
os.utime(dest, (modTime, modTime)) 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 # Remove any empty directories after moving files
def remove_empty_directories(pathlib_root_dir, pattern): def remove_empty_directories(pathlib_root_dir, pattern):
pathlib_root_dir = Path(pathlib_root_dir) pathlib_root_dir = Path(pathlib_root_dir)
@ -328,6 +336,23 @@ def nohardlink(file):
return check 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 # Gracefully kill script when docker stops
class GracefulKiller: class GracefulKiller:
kill_now = False kill_now = False