Files
mediahive/scripts/rtorrent-manager.py
T
2026-05-25 02:12:31 +00:00

451 lines
15 KiB
Python

#!/usr/bin/env python3
"""Torrent Scanner - Scans for .torrent files and analyzes their trackers."""
import argparse
import glob
import hashlib
import shutil
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import bencodepy
from rtorrent_client import RTorrentClient
@dataclass
class TorrentInfo:
"""Information extracted from a torrent file."""
path: Path
name: str
trackers: list[str]
size: int | None = None
files: list[str] | None = None
info_hash: str | None = None
is_multi_file: bool = False
def has_tracker(self, domain: str) -> bool:
"""Check if any tracker URL contains the given domain."""
return any(domain.lower() in tracker.lower() for tracker in self.trackers)
def get_download_directory(self) -> Path:
"""Get the download directory (parent of .torrents folder).
Assumes .torrent files are in <download_dir>/.torrents/
so the actual downloads are one level up.
"""
return self.path.parent.parent
def get_expected_data_path(self) -> Path:
"""Get the expected path where downloaded data should exist.
For multi-file torrents: download_dir/torrent_name/ (directory)
For single-file torrents: download_dir/torrent_name (file)
"""
return self.get_download_directory() / self.name
def verify_download_exists(self) -> tuple[bool, str]:
"""Verify that the downloaded data exists on disk.
Returns:
Tuple of (exists: bool, message: str)
"""
expected_path = self.get_expected_data_path()
if self.is_multi_file:
# Multi-file torrent: expect a directory
if not expected_path.exists():
return False, f"Directory not found: {expected_path}"
if not expected_path.is_dir():
return False, f"Expected directory but found file: {expected_path}"
# Optionally check if at least some files exist
existing_files = list(expected_path.rglob("*"))
file_count = sum(1 for f in existing_files if f.is_file())
if file_count == 0:
return False, f"Directory exists but is empty: {expected_path}"
return True, f"Directory exists with {file_count} files"
# Single-file torrent: expect a file
if not expected_path.exists():
return False, f"File not found: {expected_path}"
if expected_path.is_dir():
return False, f"Expected file but found directory: {expected_path}"
return True, f"File exists: {expected_path}"
def parse_torrent(filepath: Path) -> TorrentInfo | None:
"""Parse a .torrent file and extract relevant information.
Args:
filepath: Path to the .torrent file
Returns:
TorrentInfo object or None if parsing fails
"""
try:
with Path(filepath).open("rb") as f:
data = bencodepy.decode(f.read())
except Exception as e:
print(f"Error parsing {filepath}: {e}")
return None
# Extract trackers
trackers = []
# Main announce URL
if b"announce" in data:
announce = data[b"announce"]
if isinstance(announce, bytes):
trackers.append(announce.decode("utf-8", errors="replace"))
# Announce list (multiple trackers)
if b"announce-list" in data:
for tier in data[b"announce-list"]:
for tracker in tier:
if isinstance(tracker, bytes):
url = tracker.decode("utf-8", errors="replace")
if url not in trackers:
trackers.append(url)
# Extract name
info = data.get(b"info", {})
name = info.get(b"name", b"Unknown").decode("utf-8", errors="replace")
# Calculate info hash
info_hash = hashlib.sha1(bencodepy.encode(info)).hexdigest().upper()
# Extract size and files
size = None
files = None
is_multi_file = False
if b"length" in info:
# Single file torrent
size = info[b"length"]
files = [name]
is_multi_file = False
elif b"files" in info:
# Multi-file torrent
files = []
size = 0
is_multi_file = True
for file_info in info[b"files"]:
file_path = "/".join(
p.decode("utf-8", errors="replace") for p in file_info.get(b"path", [])
)
files.append(file_path)
size += file_info.get(b"length", 0)
return TorrentInfo(
path=filepath,
name=name,
trackers=trackers,
size=size,
files=files,
info_hash=info_hash,
is_multi_file=is_multi_file,
)
def scan_torrent_directories(paths: list[str]) -> Iterator[Path]:
"""Scan directories for .torrent files.
Args:
paths: List of directory paths or glob patterns to scan
Yields:
Path objects for each .torrent file found
"""
for pattern in paths:
for dir_path in glob.glob(pattern):
torrent_dir = Path(dir_path)
if torrent_dir.is_dir():
yield from torrent_dir.glob("*.torrent")
def find_torrents_with_tracker(
tracker_domain: str, paths: list[str]
) -> list[TorrentInfo]:
"""Find all torrents that have a specific tracker domain.
Args:
tracker_domain: Domain to search for in tracker URLs (e.g., "hdbits.org")
paths: List of directory paths or glob patterns to scan
Returns:
List of TorrentInfo objects for matching torrents
"""
matching_torrents = []
for torrent_path in scan_torrent_directories(paths):
info = parse_torrent(torrent_path)
if info and info.has_tracker(tracker_domain):
matching_torrents.append(info)
return matching_torrents
def format_size(size_bytes: int | None) -> str:
"""Format bytes as human-readable size."""
if size_bytes is None:
return "Unknown"
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} PB"
def main() -> None:
"""Main entry point for the torrent scanner."""
parser = argparse.ArgumentParser(
description="Scan and manage torrent files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s /path/to/torrents*/.torrents/
%(prog)s /mnt/disk1/torrents/.torrents/ /mnt/disk2/torrents/.torrents/
%(prog)s /torrents*/.torrents/ --tracker hdbits.org
%(prog)s /torrents*/.torrents/ --dry
""",
)
parser.add_argument(
"paths",
nargs="+",
help="Directories or glob patterns containing .torrent files",
)
parser.add_argument(
"--dry",
action="store_true",
help="Dry run - show what would be done without making changes",
)
parser.add_argument(
"--tracker",
default="hdbits.org",
help="Tracker domain to filter by (default: hdbits.org)",
)
args = parser.parse_args()
dry_run = args.dry
tracker_domain = args.tracker
# Expand glob patterns
expanded_paths = []
for pattern in args.paths:
matches = glob.glob(pattern)
if matches:
expanded_paths.extend(matches)
else:
expanded_paths.append(pattern)
if dry_run:
print("=" * 60)
print("DRY RUN MODE - No changes will be made")
print("=" * 60)
print("Scanning for torrents...")
print(f"Search paths: {expanded_paths}")
print("-" * 60)
# Parse all torrents
all_torrents: list[TorrentInfo] = []
for torrent_path in scan_torrent_directories(expanded_paths):
info = parse_torrent(torrent_path)
if info:
all_torrents.append(info)
# Separate by tracker
with_hdbits = [t for t in all_torrents if t.has_tracker(tracker_domain)]
without_hdbits = [t for t in all_torrents if not t.has_tracker(tracker_domain)]
# Print stats
print(f"\n{'=' * 60}")
print("SUMMARY")
print(f"{'=' * 60}")
print(f"Total torrents scanned: {len(all_torrents)}")
print(f"With {tracker_domain}: {len(with_hdbits)}")
print(f"Without {tracker_domain}: {len(without_hdbits)}")
# Add hdbits torrents to rtorrent
if with_hdbits:
print(f"\n{'=' * 60}")
print("VERIFYING DOWNLOADS & ADDING TO RTORRENT")
print(f"{'=' * 60}")
# First, verify which torrents have their data
verified = []
missing_data = []
for torrent in with_hdbits:
exists, message = torrent.verify_download_exists()
if exists:
verified.append(torrent)
else:
missing_data.append((torrent, message))
print("\nVerification results:")
print(f" Downloads found: {len(verified)}")
print(f" Downloads missing: {len(missing_data)}")
# Report missing downloads
if missing_data:
print(f"\n{'=' * 60}")
print("TORRENTS WITH MISSING DATA (will not add)")
print(f"{'=' * 60}")
for torrent, message in missing_data:
print(f"\n Name: {torrent.name}")
print(f" Torrent: {torrent.path}")
print(f" Reason: {message}")
# Now add verified torrents to rtorrent
if verified:
print(f"\n{'=' * 60}")
print(f"ADDING {len(verified)} VERIFIED TORRENTS TO RTORRENT")
print(f"{'=' * 60}")
client = RTorrentClient()
loaded_hashes = client.get_loaded_hashes()
print(f"Currently loaded in rtorrent: {len(loaded_hashes)} torrents")
added = 0
skipped = 0
failed = 0
for torrent in verified:
if torrent.info_hash and torrent.info_hash in loaded_hashes:
print(f"Skipping (already loaded): {torrent.name}")
skipped += 1
else:
download_dir = torrent.get_download_directory()
if dry_run:
print(f"Would add: {torrent.name}")
print(f" Download dir: {download_dir}")
added += 1
else:
print(f"Adding: {torrent.name}")
print(f" Download dir: {download_dir}")
if client.load_torrent(torrent.path, download_dir):
added += 1
else:
failed += 1
if dry_run:
print(f"\nDry run: {added} would be added, {skipped} already loaded")
else:
print(
f"\nRtorrent results: {added} added, {skipped} skipped, {failed} failed"
)
# Clean up unregistered torrents from rtorrent
print(f"\n{'=' * 60}")
print("CHECKING FOR UNREGISTERED TORRENTS")
print(f"{'=' * 60}")
client = RTorrentClient()
unregistered = client.get_unregistered_torrents()
if unregistered:
print(f"Found {len(unregistered)} unregistered torrent(s):\n")
removed_from_rtorrent = 0
removed_torrent_files = 0
removed_downloads = 0
for torrent_info in unregistered:
# Determine the download path (base_path is the actual file/folder)
download_path = (
Path(torrent_info["base_path"]) if torrent_info["base_path"] else None
)
if dry_run:
status = "[DRY]"
if download_path:
print(f" {status} {download_path}")
else:
print(f" {status} {torrent_info['name']} (no data path)")
# Remove from rtorrent (keeps downloaded files)
elif client.remove_torrent(torrent_info["hash"]):
removed_from_rtorrent += 1
# Delete the .torrent file if it exists
tied_file = torrent_info["tied_file"]
if tied_file:
torrent_file = Path(tied_file)
if torrent_file.exists():
try:
torrent_file.unlink()
removed_torrent_files += 1
except Exception:
pass
# Delete the downloaded files
if download_path and download_path.exists():
try:
if download_path.is_dir():
shutil.rmtree(download_path)
else:
download_path.unlink()
removed_downloads += 1
print(f" [DEL] {download_path}")
except Exception as e:
print(f" [ERR] {download_path}: {e}")
else:
print(f" [DEL] {torrent_info['name']} (no data)")
else:
print(f" [ERR] {torrent_info['name']}: failed to remove from rtorrent")
print()
if dry_run:
print(
f"Dry run: {len(unregistered)} would be removed (rtorrent + .torrent + downloads)"
)
else:
print(
f"Cleanup: {removed_from_rtorrent} from rtorrent, {removed_torrent_files} .torrents, {removed_downloads} downloads"
)
else:
print("No unregistered torrents found.")
# List torrents without hdbits.org
if without_hdbits:
print(f"\n{'=' * 60}")
print(f"TORRENTS WITHOUT {tracker_domain.upper()}")
print(f"{'=' * 60}")
for torrent in without_hdbits:
print(f"\nName: {torrent.name}")
print(f"Path: {torrent.path}")
print(f"Size: {format_size(torrent.size)}")
if torrent.trackers:
print("Trackers:")
for tracker in torrent.trackers:
print(f" - {tracker}")
else:
print("Trackers: (none)")
# Remove the non-hdbits torrent files
print(f"\n{'=' * 60}")
if dry_run:
print(f"WOULD REMOVE {len(without_hdbits)} TORRENT FILE(S)")
print(f"{'=' * 60}")
for torrent in without_hdbits:
print(f"Would remove: {torrent.path}")
else:
print(f"REMOVING {len(without_hdbits)} TORRENT FILE(S)")
print(f"{'=' * 60}")
for torrent in without_hdbits:
try:
torrent.path.unlink()
print(f"Removed: {torrent.path}")
except Exception as e:
print(f"Failed to remove {torrent.path}: {e}")
if __name__ == "__main__":
main()