mirror of
https://github.com/Heretek-AI/torrent-dedup.git
synced 2026-11-07 10:06:02 -10:00
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.
182 lines
5.8 KiB
Python
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())
|