Files
torrent-dedup/filter_duplicates.py
John Smith da9de2b766 feat: add entry-only duplicate removal pipeline
Collect live inventories from all qBittorrent hosts, choose one complete keeper per duplicated infohash (most free disk), and apply reviewed deletions with entry-only deletes. Every deletion is backed up as a .torrent file first so qui Orphan Scan can reclaim the files afterwards. Includes 32 tests covering login styles, pagination, keeper selection, vetoes, backups, and failure handling.
2026-11-05 23:46:27 -05:00

182 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Keep only infohashes that appear on two or more hosts in torrent-hashes.csv.
Reads the column-per-host CSV produced by dump_torrent_hashes.py, counts how
many distinct host columns contain each infohash, and writes a new CSV with the
same columns but only the hashes that occur on at least --min-hosts hosts.
Hashes unique to a single host are removed.
The input file is never modified.
Usage:
python filter_duplicates.py
python filter_duplicates.py --min-hosts 2 --out torrent-hashes-duplicates.csv
"""
from __future__ import annotations
import argparse
import csv
import sys
from collections import Counter
from pathlib import Path
from typing import Sequence
DEFAULT_INPUT = "torrent-hashes.csv"
DEFAULT_OUTPUT = "torrent-hashes-duplicates.csv"
def read_table(path: Path) -> tuple[list[str], list[list[str]]]:
if not path.is_file():
raise SystemExit(f"error: input file not found: {path}")
# utf-8-sig tolerates a BOM if the CSV was re-saved by Excel.
with path.open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.reader(fh)
try:
header = next(reader)
except StopIteration:
raise SystemExit(f"error: input file is empty: {path}")
rows = [row for row in reader if any(cell.strip() for cell in row)]
width = len(header)
if width == 0:
raise SystemExit(f"error: input file has no host columns: {path}")
normalized: list[list[str]] = []
for line_number, row in enumerate(rows, start=2):
if len(row) > width:
raise SystemExit(
f"error: row {line_number} has {len(row)} cells but the header has {width}"
)
normalized.append(row + [""] * (width - len(row)))
return header, normalized
def count_hosts(
header: Sequence[str], rows: Sequence[Sequence[str]]
) -> dict[str, set[int]]:
"""Map each infohash to the set of host columns that contain it."""
hosts_by_hash: dict[str, set[int]] = {}
for column in range(len(header)):
for row in rows:
value = row[column].strip().lower()
if value:
hosts_by_hash.setdefault(value, set()).add(column)
return hosts_by_hash
def build_columns(
header: Sequence[str],
hosts_by_hash: dict[str, set[int]],
min_hosts: int,
) -> tuple[list[list[str]], int]:
"""Build one filtered, sorted list of hashes per host column."""
per_column: list[list[str]] = [[] for _ in header]
kept = 0
for value, host_columns in hosts_by_hash.items():
if len(host_columns) >= min_hosts:
kept += 1
for column in host_columns:
per_column[column].append(value)
for values in per_column:
values.sort()
return per_column, kept
def write_table(
path: Path, header: Sequence[str], columns: Sequence[Sequence[str]]
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
row_count = max((len(values) for values in columns), default=0)
with path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(header)
for index in range(row_count):
writer.writerow(
[values[index] if index < len(values) else "" for values in columns]
)
return row_count
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Remove infohashes that appear on only one host from torrent-hashes.csv.",
)
parser.add_argument(
"--input",
default=DEFAULT_INPUT,
help=f"column-per-host CSV to read (default: {DEFAULT_INPUT})",
)
parser.add_argument(
"--out",
default=DEFAULT_OUTPUT,
help=f"filtered CSV to write (default: {DEFAULT_OUTPUT})",
)
parser.add_argument(
"--min-hosts",
type=int,
default=2,
help="keep hashes present on at least this many hosts (default: 2)",
)
args = parser.parse_args(argv)
if args.min_hosts < 2:
parser.error("--min-hosts must be at least 2")
return args
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
input_path = Path(args.input)
output_path = Path(args.out)
if input_path.resolve() == output_path.resolve():
print(
"error: refusing to overwrite the input file; choose a different --out",
file=sys.stderr,
)
return 2
header, rows = read_table(input_path)
hosts_by_hash = count_hosts(header, rows)
per_column, kept = build_columns(header, hosts_by_hash, args.min_hosts)
row_count = write_table(output_path, header, per_column)
entries = sum(1 for row in rows for cell in row if cell.strip())
removed = len(hosts_by_hash) - kept
spread = Counter(
len(host_columns)
for host_columns in hosts_by_hash.values()
if len(host_columns) >= args.min_hosts
)
extra_copies = (
sum(
len(host_columns)
for host_columns in hosts_by_hash.values()
if len(host_columns) >= args.min_hosts
)
- kept
)
print(f"Read {len(rows)} rows x {len(header)} hosts from {input_path}")
print(f" {entries} hash entries, {len(hosts_by_hash)} distinct hashes")
print(f" {removed} hashes on fewer than {args.min_hosts} hosts (removed)")
print(f" {kept} duplicated hashes kept ({extra_copies} extra copies)")
for count in sorted(spread):
print(f" on exactly {count} hosts: {spread[count]}")
print(
f"Wrote {output_path}: {kept} hashes across {len(header)} columns ({row_count} rows)"
)
if not kept:
print(
"warning: no duplicate hashes found; output contains only the header",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())