Last active
January 29, 2025 20:24
-
-
Save zakkarry/3f690bcd56bbfa00c4d72c97d24f2620 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
### ---------- SETUP ---------- ### | |
# python 3.12+ ( was not extensively tested on pre-3.12 versions!! ) | |
# pip install bencodepy | |
## AUTHORED BY zakkarry with help from Q | |
### ---------- SETUP ---------- ### | |
### ---------- INSTRUCTIONS ---------- ### | |
### ---------- INSTRUCTIONS ---------- ### | |
### ---------- INSTRUCTIONS ---------- ### | |
# | |
# PLEASE READ THE WHOLE INSTRUCTIONS BLOCK AND DOCUMENTATION PRIOR TO USING THIS! | |
# THIS IS ENTIRELY OPTIONAL AND IF YOU DONT FOLLOW THESE INSTRUCTIONS YOU CAN RISK | |
# DATA LOSS!!! | |
# | |
# THIS IS NOT REQUIRED AND IS ENTIRELY OPTIONAL FOR LINKING STRUCTURE. | |
# If you have an exotic setup, this script MAY NOT work for you. Please be sure to make and make keep your backups | |
# in case anything goes wrong. We will create our own backups, but you should have your own anyway! | |
# | |
# If DRY_RUN set to True gives no errors or log bad reports, it should be able to run without issues. | |
# | |
# DO NOT RUN WITHOUT READING ALL OF THE INSTRUCTIONS FIRST AND VALIDATING DRY RUNS !!!!!! | |
# This script will convert all cross-seed torrents in client | |
# to link at LINK_DIR/TrackerName, as v6 `flatlinking: false` does in normal operation. | |
# Torrent with save paths already located inside the associated TrackerName will be ignored. | |
# | |
# WARNING: You will likely need to clean up orphaned files following this script | |
# | |
# This script does not remove the non linked/abandoned "original" torrent's data files | |
# I simply use this to link all the files with this script that weren't in tracker folders to an "OLD_CROSSEEDS" | |
# until I was sure everything was running correctly and working. Then I removed the old structured files/folders | |
# when I knew that what I had before was no longer needed. | |
# | |
# Torrents that are missing any/all of their files will | |
# immediately error in Deluge upon restarting. When you restart after running this, you will see if files are missing | |
# right away. | |
# ## INSTRUCTIONS ## | |
# ## | |
# ## PLEASE READ ALL OF THESE ONCE OR TWICE BEFORE DOING ANYTHING ELSE! | |
# ## | |
# 1. Populate ONLY the options in the CONFIG section below according to your cross-seed setup | |
# THESE PATHS USED BELOW ARE HOW CROSS-SEED AND YOUR TORRENT CLIENT KNOW THE PATHS | |
# IF YOU ARE USING DOCKER THESE ARE YOUR CONTAINER PATHS, NOT THE HOST SYSTEMS PATHS | |
# 2. If you are using docker, you'll need to get a shell/console inside the container and stop the deluged process | |
# | |
# To do this, use "docker exec -it deluge /bin/sh" | |
# | |
# Replace "deluge" in this command with your container name if it is not default! | |
# | |
# - linuxserver (lscr) containers need to get a shell in the container and run the command | |
# s6-svc -d /run/service/svc-deluged | |
# | |
# - binhex containers need to run the command | |
# kill -15 $(ps aux | grep /usr/bin/deluged | awk '{print $2}') | |
# | |
# BEFORE PROCEEDING - VALIDATE THAT DELUGEd (core) PROCESS HAS STOPPED AND NOT AUTOMATICALLY RESTARTED! | |
# USE "ps aux|grep deluged" to confirm in addition to checking your webui - the webui should stay running for docker | |
# native users can just stop the entire deluge "suite" | |
# | |
# 3. Non docker needs to STOP deluge in a way that it will not restart automatically (eg. systemctl or service or regular exit) | |
# | |
# 4. Once deluge has exited, Make a manual _FULL_ backup of the torrents.state and torrents.fastresume files | |
# (this script will backup before modifying files as well) | |
# | |
# 5. Run the script with DRY_RUN = True (SET TO TRUE WHEN YOU DOWNLOADED THIS) and check the console output or logs for | |
# the paths to ensure they are correct for you | |
# 6. After verifying everything is good, run the script with DRY_RUN = False to create the links and modify your torrent state file. | |
# It will make a backup as well | |
# | |
# 7. After running non-dry run, retart Deluge (or restart the container) and check if any torrents are in Error state, check the | |
# paths of a few of the changed torrents, pause and recheck a couple for verification | |
# 8. If anything is wrong, STOP Deluge (or the container) entirely, replace the .state and .fastresume files from either BACKUP, and start Deluge. | |
# 9. You may need to re-run the script a second time. There is no risk in retrying if something appears wrong. Just keep backups of the original files! | |
# Always make sure to exit deluged beforehand. | |
# | |
# 9. If everything is correct, you will NEED to clean up/move the orphaned files as they are no longer being used by your client. | |
# This script does not remove files, only creates links to the new location | |
# | |
# PLEASE NOTE: If you have any concerns, questions, or hesitation, please contact the cross-seed discord for support. | |
### ---------- END INSTRUCTIONS ---------- ### | |
### ---------- END INSTRUCTIONS ---------- ### | |
### ---------- END INSTRUCTIONS ---------- ### | |
### ---------- CONFIG ---------- | |
### ---------- CONFIG ---------- | |
### ---------- CONFIG ---------- | |
# Set to True to only log what would be done | |
DRY_RUN = True | |
# This must be the path from the prespective of this script | |
# It may differ from the path you have in cross-seed | |
# THIS WILL BE WHERE YOUR .torrent and .state and .fastresume for deluge reside! | |
# QUIT Deluge and BACKUP torrents.state and torrents.fastresume MANUALLY before running this script | |
# KEEP THE BACKUPS OF THE FILES SAFE FOR NOW! | |
TORRENT_DIR = "/config/state" | |
# this is either "symlink" or "hardlink" ONLY | |
LINK_TYPE = "hardlink" | |
# This must be the path from the prespective of this script | |
# It MAY differ from the path you have in cross-seed | |
# If script says lots of files are not found, this may be the cause | |
LINK_DIR = "/data/linkdir" | |
# The key (left side of ":") is a unique portion of the tracker url or announce id, | |
# the value (right side of ":") is the folder name for torrents associated with that tracker | |
# The url is for the tracker, NOT the website. View the tracker urls for a torrent in client (announce url) for correct url | |
# You don't need the entire url, just a unique part of it | |
# Any torrents without one of these will be ignored | |
# Some example tems are pre-filed with tracker url and announce id | |
TRACKER_NAME_URL = { | |
"identifiable-url-snippet.com": "tracker_linking_subfolder", | |
"thepiratebay.com": "tpb", | |
"l33tx": "leetx", | |
} | |
### ---------- END CONFIG ---------- | |
### ---------- END CONFIG ---------- | |
### ---------- END CONFIG ---------- | |
import logging | |
import os | |
import pickle | |
import re | |
import shutil | |
import sys | |
from collections import OrderedDict | |
from pathlib import Path | |
from typing import Any, NewType | |
from urllib.parse import urlparse | |
try: | |
import bencodepy # type: ignore | |
except ModuleNotFoundError: | |
print(r"bencode module missing. please run 'pip install bencodepy'") | |
exit() | |
except KeyboardInterrupt: | |
exit() | |
# types | |
FastResume = NewType("FastResume", OrderedDict[bytes, Any]) | |
assert sys.version_info >= (3, 12), "Python 3.12+ required" | |
assert isinstance( | |
DRY_RUN, bool | |
), "DRY_RUN must be a boolean: DRY_RUN = True or DRY_RUN = False" | |
assert isinstance( | |
TORRENT_DIR, str | |
), 'TORRENT_DIR must be a string like: TORRENT_DIR = "/path/to/folder"' | |
TORRENT_DIR_PATH = Path(TORRENT_DIR) | |
assert ( | |
TORRENT_DIR_PATH.exists() and TORRENT_DIR_PATH.is_dir() | |
), f"TORRENT_DIR does not exist or is not a folder: {TORRENT_DIR}" | |
assert LINK_TYPE in [ | |
"hardlink", | |
"symlink", | |
], 'LINK_TYPE must be either "hardlink" or "symlink": LINK_TYPE = "hardlink"' | |
assert isinstance( | |
LINK_DIR, str | |
), 'LINK_DIR must be a string like: LINK_DIR = Path("/path/to/folder")' | |
LINK_DIR_PATH = Path(LINK_DIR) | |
assert ( | |
Path(LINK_DIR_PATH).exists() and Path(LINK_DIR_PATH).is_dir() | |
), f"LINK_DIR does not exist or is not a folder: {LINK_DIR}" | |
assert ( | |
len(TRACKER_NAME_URL) > 0 | |
), 'TRACKER_NAME_URL must be a dictionary like: TRACKER_NAME_URL = {"https://tracker.url": "tracker_name"}' | |
# Logging | |
LOG_NAME = f"{Path(__file__).stem}.log" | |
LOG_NAME = f"/media/file2.log" | |
LOG_FORMAT_DATE = "%Y-%m-%d %H:%M:%S" | |
LOG_FORMAT_STREAM = "%(asctime)s.%(msecs)03d %(levelname)s: %(message)s" | |
LOG_FORMAT_FILE = "%(asctime)s.%(msecs)03d %(levelname)s: %(message)s" | |
LOG_HANDLER_STREAM = logging.StreamHandler(sys.stdout) | |
LOG_HANDLER_FILE = logging.FileHandler(LOG_NAME) | |
LOG_HANDLER_STREAM.setFormatter(logging.Formatter(LOG_FORMAT_STREAM, LOG_FORMAT_DATE)) | |
LOG_HANDLER_FILE.setFormatter(logging.Formatter(LOG_FORMAT_FILE, LOG_FORMAT_DATE)) | |
log = logging.getLogger(LOG_NAME) | |
log.setLevel(logging.DEBUG) | |
LOG_HANDLER_STREAM.setLevel(logging.DEBUG) | |
LOG_HANDLER_STREAM.setLevel(logging.DEBUG) | |
log.addHandler(LOG_HANDLER_STREAM) | |
log.addHandler(LOG_HANDLER_FILE) | |
def sanitize_infohash(infohash: str): | |
if not re.compile(r"^[a-z0-9]{40}$").match(infohash): | |
return infohash | |
san_string = infohash[:5] + "..." + infohash[35:] | |
return san_string | |
def get_torrent_size(torrent_path): | |
with open(torrent_path, "rb") as f: | |
torrent_data = bencodepy.decode(f.read()) | |
info = torrent_data[b"info"] | |
if b"length" in info: | |
# Single-file torrent | |
return info[b"length"] | |
elif b"files" in info: | |
# Multi-file torrent | |
total_size = sum(file_sizes[b"length"] for file_sizes in info[b"files"]) | |
return total_size | |
else: | |
return 0 | |
def get_torrent_name(torrent_path): | |
with open(torrent_path, "rb") as f: | |
torrent_data = bencodepy.decode(f.read()) | |
info = torrent_data[b"info"] | |
if b"name" in info: | |
return info[b"name"].decode("utf-8") # Decode bytes to string | |
# Backup TORRENT_STATE_FILE | |
TORRENT_STATE_FILE = TORRENT_DIR_PATH / "torrents.state" | |
TORRENT_STATE_OLD = TORRENT_STATE_FILE.with_suffix(".state.old") | |
TORRENT_FAST_RESUME = TORRENT_DIR_PATH / "torrents.fastresume" | |
assert ( | |
not TORRENT_STATE_OLD.exists() | |
), f"TORRENT_STATE_OLD already exists, move it somewhere safe before rerunning this script: {TORRENT_STATE_OLD}" | |
log.info( | |
f"{'(DRY RUN) ' if DRY_RUN else ''}Backing up TORRENT_STATE_FILE to TORRENT_DIR: {TORRENT_STATE_FILE}" | |
) | |
if not DRY_RUN: | |
shutil.copyfile(TORRENT_STATE_FILE, TORRENT_STATE_OLD) | |
assert ( | |
TORRENT_STATE_OLD.exists() | |
), f"Failed to copy TORRENT_STATE_FILE state file to TORRENT_STATE_OLD: {TORRENT_STATE_FILE} -> {TORRENT_STATE_OLD}" | |
TORRENT_CATEGORY_SUFFIX = ".cross-seed" # Not bytes | |
log.info( | |
f"{'(DRY RUN) ' if DRY_RUN else ''}Processing cross-seed torrents in TORRENT_DIR: {TORRENT_DIR}" | |
) | |
total_linked: int = 0 | |
state_file = open(TORRENT_STATE_FILE, "rb") | |
state = pickle.load(state_file) | |
state_file.close() | |
state_modified = False | |
fastresume: FastResume = bencodepy.decode_from_file(TORRENT_FAST_RESUME) # type: ignore | |
fastresume_decoded = {k.decode(): v for k, v in fastresume.items()} | |
for torrent in state.torrents: | |
migrated_full_path: None | Path = None | |
migrated_path: None | str = None | |
torrent_infohash: str = torrent.torrent_id | |
torrent_name: str = get_torrent_name( | |
TORRENT_DIR_PATH / f"{torrent_infohash}.torrent" | |
) | |
torrent_tracker_count: int = len(torrent.trackers) | |
torrent_tracker_urls: list[str] = [ | |
torrent.trackers[x].get("url", None) for x in range(torrent_tracker_count) | |
] | |
torrent_is_finished: bool = torrent.is_finished | |
torrent_save_path: Path = torrent.save_path | |
torrent_total_size: int = int( | |
round( | |
get_torrent_size(TORRENT_DIR_PATH / f"{torrent_infohash}.torrent") | |
/ 1024 | |
/ 1024, | |
1, | |
) | |
) | |
to_decode_infohash = None | |
if torrent_infohash in fastresume_decoded: | |
to_decode_infohash = fastresume_decoded.get(torrent_infohash, None) | |
if isinstance(to_decode_infohash, bytes): | |
torrent_fast_resume = bencodepy.decode(to_decode_infohash) | |
else: | |
continue | |
torrent_is_renamed = torrent_fast_resume.get(b"mapped_files", None) | |
if torrent_is_renamed: | |
continue | |
torrent_total_downloaded: int = int( | |
round(torrent_fast_resume.get(b"total_downloaded", 0) / 1024 / 1024, 1) | |
) | |
torrent_download_percent: int = round(torrent_total_downloaded / torrent_total_size) | |
if ( | |
torrent_save_path | |
and len(torrent_tracker_urls) >= 1 | |
and (val in torrent_save_path for val in TRACKER_NAME_URL.values()) | |
): | |
results: dict[str, bool] = { | |
key: any(key in url for url in torrent_tracker_urls) | |
for key in TRACKER_NAME_URL | |
} | |
for key, result in results.items(): | |
if not result: | |
continue | |
else: | |
migrated_path = TRACKER_NAME_URL.get(key, None) | |
migrated_full_path = LINK_DIR_PATH / migrated_path | |
break | |
if ( | |
not migrated_path | |
or migrated_path in torrent_save_path | |
or (torrent_download_percent > 0.8) | |
): | |
continue | |
print("\n") | |
log.debug( | |
f"(LINKING) {urlparse(torrent_tracker_urls[0]).netloc} in {sanitize_infohash(torrent_name)} ({sanitize_infohash(torrent_infohash)})" | |
) | |
log.debug( | |
f"\t\t(downloaded: {torrent_total_downloaded}MB/{torrent_total_size}MB) - {round(torrent_download_percent * 100, 1)}%" | |
) | |
log.debug(f"\t\t(save_path: {torrent_save_path} -> {migrated_full_path})") | |
state_modified = True | |
torrent.save_path = torrent.save_path.replace( | |
torrent_save_path, f"{migrated_full_path}" | |
) | |
torrent_src_path = Path(torrent_save_path) / torrent_name | |
torrent_dest_path = Path(migrated_full_path) / torrent_name | |
if torrent_src_path.is_dir(): | |
for root, dirs, files in os.walk(torrent_src_path): | |
rel_path = Path(os.path.relpath(root, torrent_src_path)) | |
dest_dir = Path(os.path.join(torrent_dest_path, rel_path)) | |
if not os.path.exists(dest_dir): | |
if not DRY_RUN: | |
dest_dir.mkdir(parents=True, exist_ok=True) | |
log.debug(f"(MKDIR) making parent directory -> {dest_dir}") | |
file_count = 1 | |
for file in files: | |
source_file = Path(os.path.join(root, file)) | |
dest_file = Path(os.path.join(dest_dir, file)) | |
torrent_type = ( | |
"of a multi-file" if len(files) > 1 else "single-file" | |
) | |
torrent_file_count = ( | |
f"({file_count}/{len(files)}) " if len(files) > 1 else "" | |
) | |
log.debug( | |
f"({LINK_TYPE.upper()}) linking {torrent_file_count}{torrent_type} torrent ({sanitize_infohash(torrent_infohash)})\n\t\t\t\t\tSRC: {source_file}\n\t\t\t\t\tDEST: {dest_file}" | |
) | |
if not DRY_RUN: | |
if not dest_file.exists(): | |
if LINK_TYPE == "hardlink": | |
dest_file.hardlink_to(source_file) | |
else: | |
dest_file.symlink_to(source_file) | |
file_count += 1 | |
total_linked += 1 | |
else: | |
log.debug( | |
f"({LINK_TYPE.upper()}) linking single-file torrent ({sanitize_infohash(torrent_infohash)})\n\t\t\t\t\tSRC: {torrent_src_path}\n\t\t\t\t\tDEST: {torrent_dest_path}" | |
) | |
if not DRY_RUN: | |
if not torrent_dest_path.exists(): | |
if LINK_TYPE == "hardlink": | |
torrent_dest_path.hardlink_to(torrent_src_path) | |
else: | |
torrent_dest_path.symlink_to(torrent_src_path) | |
total_linked += 1 | |
print() | |
if state_modified: | |
if not DRY_RUN: | |
log.debug(f"YOU ARE NOT RUNNING IN DRY RUN!!!\n") | |
shutil.copyfile(TORRENT_STATE_FILE, TORRENT_STATE_OLD) | |
state_file = open(TORRENT_STATE_FILE, "wb") | |
pickle.dump(state, state_file) | |
state_file.close() | |
else: | |
log.debug(f"YOU ARE RUNNING IN DRY RUN!!!\n") | |
else: | |
log.debug("No modifications were determined to need to be done!\n") | |
log.info( | |
f"{'(DRY RUN) ' if DRY_RUN else ''}Total newly linked torrents: {total_linked}" | |
) |
Comments are disabled for this gist.