Created
May 7, 2026 03:01
-
-
Save kism/3c7763a28de10af3e9d86c11163b4713 to your computer and use it in GitHub Desktop.
Minerva Torrent Renamer
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
| #!/usr/bin/env python3 | |
| """ | |
| qbt-duplicate-name-renamer | |
| =================== | |
| Renames duplicate qBittorrent torrents based on their inner folder structure. | |
| This will work with any series of torrents with the same name and a linear folder structure, but is designed for the Minerva torrents. | |
| This is 100% AI generated with Claude Sonnet 4.6, I tested it on my qBittorrent instance and it worked fine. | |
| Requirements | |
| ------------ | |
| Python 3.12+ | |
| Setup | |
| ----- | |
| 1. Download this file. | |
| 2. In the same folder, create a file called `.env` with your qBittorrent details: | |
| QBT_HOST=192.168.1.x # required — IP or hostname of your qBittorrent WebUI | |
| QBT_PORT=8080 # optional, default: 8080 | |
| QBT_USERNAME=admin # optional | |
| QBT_PASSWORD=yourpass # optional | |
| 3. Create a virtual environment and install dependencies: | |
| python -m venv .venv | |
| # Windows: | |
| .venv\\Scripts\\activate | |
| # macOS/Linux: | |
| source .venv/bin/activate | |
| pip install qbittorrent-api python-dotenv | |
| 4. Run (dry run — no changes made): | |
| python qbt-duplicate-name-renamer.py | |
| 5. If the proposed renames look correct, apply them: | |
| python qbt-duplicate-name-renamer.py --actually-rename-my-torrents | |
| uv users | |
| -------- | |
| uv run qbt-duplicate-name-renamer.py | |
| uv run qbt-duplicate-name-renamer.py --actually-rename-my-torrents | |
| """ | |
| import argparse | |
| import os | |
| from collections import defaultdict | |
| from dataclasses import dataclass, field | |
| import qbittorrentapi | |
| from dotenv import load_dotenv | |
| @dataclass | |
| class TorrentRenameProposal: | |
| torrent_hash: str | |
| current_name: str | |
| proposed_name: str | |
| depth: int | |
| @dataclass | |
| class DuplicateGroup: | |
| name: str | |
| proposals: list[TorrentRenameProposal] = field(default_factory=list) | |
| def build_dir_tree(file_paths: list[str]) -> dict: | |
| """Build a nested dict representing the directory tree. Leaf files are None.""" | |
| tree: dict = {} | |
| for path in file_paths: | |
| parts = path.replace("\\", "/").split("/") | |
| node = tree | |
| for part in parts[:-1]: | |
| node = node.setdefault(part, {}) | |
| node[parts[-1]] = None | |
| return tree | |
| def find_proposed_name(tree: dict, torrent_name: str) -> tuple[str, int]: | |
| """ | |
| Walk down single-folder branches starting from the torrent root. | |
| Returns (proposed_name, depth). depth=0 means no rename is proposed. | |
| proposed_name is the full path joined with ' / ', e.g.: | |
| 'Minerva_Myrient / No-Intro / Nintendo - Game Boy Color' | |
| Only descends when a node contains exactly one subdirectory and no files. | |
| """ | |
| root = tree.get(torrent_name) | |
| if not isinstance(root, dict): | |
| return torrent_name, 0 | |
| current_node = root | |
| path_parts = [torrent_name] | |
| while True: | |
| subdirs = {k: v for k, v in current_node.items() if isinstance(v, dict)} | |
| files = [k for k, v in current_node.items() if v is None] | |
| if len(subdirs) == 1 and len(files) == 0: | |
| name, node = next(iter(subdirs.items())) | |
| path_parts.append(name) | |
| current_node = node | |
| else: | |
| break | |
| depth = len(path_parts) - 1 | |
| return " / ".join(path_parts), depth | |
| def connect_to_qbittorrent() -> qbittorrentapi.Client: | |
| host = os.environ.get("QBT_HOST") | |
| if not host: | |
| raise RuntimeError( | |
| "QBT_HOST is not set. Configure the following environment variables " | |
| "(or place them in a .env file):\n\n" | |
| " export QBT_HOST='<hostname_or_ip>'\n" | |
| " export QBT_PORT='<port>'\n" | |
| " export QBT_USERNAME='<username>'\n" | |
| " export QBT_PASSWORD='<password>'\n" | |
| ) | |
| port = int(os.environ.get("QBT_PORT", "8080")) | |
| username = os.environ.get("QBT_USERNAME", "") | |
| password = os.environ.get("QBT_PASSWORD", "") | |
| client = qbittorrentapi.Client( | |
| host=host, | |
| port=port, | |
| username=username, | |
| password=password, | |
| ) | |
| client.auth_log_in() | |
| return client | |
| def get_duplicate_groups(client: qbittorrentapi.Client) -> list[DuplicateGroup]: | |
| torrents = client.torrents_info() | |
| name_groups: dict[str, list] = defaultdict(list) | |
| for torrent in torrents: | |
| name_groups[torrent.name].append(torrent) | |
| duplicate_groups = [] | |
| for name, group_torrents in name_groups.items(): | |
| if len(group_torrents) < 2: | |
| continue | |
| group = DuplicateGroup(name=name) | |
| for torrent in group_torrents: | |
| files = client.torrents_files(torrent_hash=torrent.hash) | |
| file_paths = [f.name for f in files] | |
| tree = build_dir_tree(file_paths) | |
| proposed_name, depth = find_proposed_name(tree, torrent.name) | |
| if depth > 0: | |
| group.proposals.append( | |
| TorrentRenameProposal( | |
| torrent_hash=torrent.hash, | |
| current_name=torrent.name, | |
| proposed_name=proposed_name, | |
| depth=depth, | |
| ) | |
| ) | |
| if group.proposals: | |
| duplicate_groups.append(group) | |
| return duplicate_groups | |
| def main() -> None: | |
| load_dotenv() | |
| parser = argparse.ArgumentParser( | |
| description="Rename qBittorrent torrents based on inner folder structure" | |
| ) | |
| parser.add_argument( | |
| "--actually-rename-my-torrents", | |
| action="store_true", | |
| help="Apply renames via the API (default is dry-run)", | |
| ) | |
| args = parser.parse_args() | |
| try: | |
| client = connect_to_qbittorrent() | |
| except RuntimeError as e: | |
| print(f"Error: {e}") | |
| raise SystemExit(1) | |
| groups = get_duplicate_groups(client) | |
| if not groups: | |
| print("No duplicate torrent groups with rename proposals found.") | |
| return | |
| for group in groups: | |
| print(f"\nGroup: '{group.name}'") | |
| for proposal in group.proposals: | |
| print( | |
| f" [{proposal.torrent_hash[:8]}] " | |
| f"'{proposal.current_name}' -> '{proposal.proposed_name}' " | |
| f"(depth={proposal.depth})" | |
| ) | |
| if args.actually_rename_my_torrents: | |
| print("\nApplying renames...") | |
| for group in groups: | |
| for proposal in group.proposals: | |
| client.torrents_rename( | |
| torrent_hash=proposal.torrent_hash, | |
| new_torrent_name=proposal.proposed_name, | |
| ) | |
| print( | |
| f" Renamed: '{proposal.current_name}' -> '{proposal.proposed_name}'" | |
| ) | |
| print("Done.") | |
| else: | |
| print("\nDry run. Use --actually-rename-my-torrents to apply changes.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment