Last active
March 19, 2026 16:42
-
-
Save Chrysaloid/0bebd37e93a92922c6cd99bcf701e5dd to your computer and use it in GitHub Desktop.
A python script solving https://github.com/qbittorrent/qBittorrent/issues/7999
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
| import os | |
| os.environ["NO_COLOR"] = "1" | |
| import shutil | |
| import subprocess | |
| import sys | |
| import traceback | |
| torrentParams = "" | |
| try: | |
| import libtorrent as lt | |
| from send2trash import send2trash as _send2trash | |
| """ qBittorrent settings info | |
| %N: Torrent name | |
| %L: Category | |
| %G: Tags (separated by comma) | |
| %F: Content path (same as root path for multifile torrent) | |
| %R: Root path (first torrent subdirectory path) | |
| %D: Save path | |
| %C: Number of files | |
| %Z: Torrent size (bytes) | |
| %T: Current tracker | |
| %I: Info hash v1 (or '-' if unavailable) | |
| %J: Info hash v2 (or ‘-' if unavailable) | |
| %K: Torrent ID (either sha-1 info hash for v1 torrent or truncated sha-256 info hash for v2/hybrid torrent) | |
| """ | |
| class SimpleError(Exception): pass | |
| # def send2trash(path: str): os.remove(path) | |
| def send2trash(path: str): _send2trash(os.path.normpath(path)) | |
| load_torrent_limits = { # https://www.rasterbar.com/products/libtorrent/reference-Torrent_Info.html#load_torrent_limits | |
| "max_buffer_size": 250 * 1024 * 1024 # 250 MiB | |
| } | |
| def getTorrentHashes(path: str): | |
| info = lt.torrent_info(path, load_torrent_limits) | |
| hashes = info.info_hashes() | |
| v1 = hashes.v1.to_string().hex() if hashes.has_v1() else None | |
| v2 = hashes.v2.to_string().hex() if hashes.has_v2() else None | |
| return v1, v2 | |
| def showErrorInNewConsole(errorText: str): | |
| subprocess.Popen( | |
| ["python", "-c", f"print({errorText!r}); input('Press ENTER to exit...')"], | |
| creationflags=subprocess.CREATE_NEW_CONSOLE, | |
| ) | |
| def searchFolderForMatchingTorrent(folder: str, v1Hash: str, v2Hash: str) -> os.DirEntry | None: | |
| with os.scandir(folder) as it: # FileNotFoundError will be raised if folder does not exist | |
| for entry in it: | |
| if entry.name.endswith(".torrent") and entry.is_file(): | |
| v1, v2 = getTorrentHashes(entry.path) | |
| if v2 == v2Hash or v1 == v1Hash: # if v2 is not available the comparison None == "-" will return False and v1 will be compared instead | |
| return entry # only 1 .torrent file should have matching Torrent ID so we stop scanning | |
| return None | |
| paramNames = [ | |
| "Torrent name", | |
| "Category", | |
| "Tags", | |
| "Content path", | |
| "Root path", | |
| "Save path", | |
| "Number of files", | |
| "Torrent size", | |
| "Current tracker", | |
| "Info hash v1", | |
| "Info hash v2", | |
| "Torrent ID", | |
| "Download folder", | |
| "Added torrents folder", | |
| "Completed torrents folder", | |
| ] | |
| maxWidth = len(max(paramNames, key=len)) | |
| params = dict() | |
| for i, value in enumerate(sys.argv[1:]): | |
| torrentParams += f"{paramNames[i].ljust(maxWidth)} : {value}\n" # for debbuging in case of an error | |
| params[paramNames[i]] = value | |
| # Root path is empty if torrent contains a single file and subfolder was not created | |
| destDir = params["Root path"] if params["Root path"] else params["Save path"] | |
| v1Hash = params["Info hash v1"] | |
| v2Hash = params["Info hash v2"] | |
| destTorrent = searchFolderForMatchingTorrent(destDir , v1Hash, v2Hash) if os.path.isdir(destDir) else None | |
| downloadTorrent = searchFolderForMatchingTorrent(params["Download folder"] , v1Hash, v2Hash) | |
| addedTorrent = searchFolderForMatchingTorrent(params["Added torrents folder"] , v1Hash, v2Hash) | |
| completedTorrent = searchFolderForMatchingTorrent(params["Completed torrents folder"], v1Hash, v2Hash) | |
| torrentFile = None | |
| if destTorrent: # the torrent is already in the destination folder so move to trash the unneeded torrents | |
| if downloadTorrent : send2trash(downloadTorrent .path) | |
| if addedTorrent : send2trash(addedTorrent .path) | |
| if completedTorrent: send2trash(completedTorrent.path) | |
| elif downloadTorrent: | |
| torrentFile = downloadTorrent | |
| if addedTorrent : send2trash(addedTorrent .path) | |
| if completedTorrent: send2trash(completedTorrent.path) | |
| elif addedTorrent: | |
| torrentFile = addedTorrent | |
| if completedTorrent: send2trash(completedTorrent.path) | |
| elif completedTorrent: | |
| torrentFile = completedTorrent | |
| elif v1Hash == "-" and v2Hash != "-": | |
| # if a v2-only torrent was added via magnet link and only 1 small file was selected for | |
| # download, torrent file is not created on completion. But if, after initial completion, you | |
| # select a larger file for download and it completes, the torrent file will be created and the | |
| # script will be called again | |
| pass | |
| else: # shouldn't happen | |
| raise SimpleError("No mathing torrents found") | |
| if torrentFile: | |
| os.makedirs(destDir, exist_ok=True) | |
| shutil.move(torrentFile.path, os.path.join(destDir, torrentFile.name)) | |
| except SimpleError as e: | |
| showErrorInNewConsole(f"{torrentParams}\nERROR: {e}\n") | |
| except Exception: | |
| showErrorInNewConsole(f"{torrentParams}\n{traceback.format_exc()}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment