Skip to content

Instantly share code, notes, and snippets.

@kism
Created May 7, 2026 03:01
Show Gist options
  • Select an option

  • Save kism/3c7763a28de10af3e9d86c11163b4713 to your computer and use it in GitHub Desktop.

Select an option

Save kism/3c7763a28de10af3e9d86c11163b4713 to your computer and use it in GitHub Desktop.
Minerva Torrent Renamer
#!/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