SlopScore
00 crowd

Torrent-disk-auditor

a single-file + single-run python script to essentially audit large drives of torrent files. The idea is to help you understand what's on their drive, what's being seeded, what's not, duplicate files that can be replaced by hardlinks, and what's wasting space.
Open repo on GitHubgithub.com/Turingwlen/Torrent-disk-auditor
Python · ★ 2 · 1 forks · Unlicense · paperwork by the Cap'mmostly ai (inferred)light human (inferred)works-on-my-machine (inferred)other
listed 1 hour ago by Turingwlen · last checked 1 hour ago
The owner didn't write this. This repo never submitted itself. The Cap'm found it on a truffle trawl and wrote its paperwork from what GitHub already shows. Picked by hand by the Cap'm on 2026-09-26: a single-file + single-run python script to essentially audit large drives of torrent files. The idea is to he; its own README says "AI Usage this script was written entirely by AI (Claude Sonnet + Opus)". 2 stars; Unlicense license. The owner did not submit this. Votes count; awards don't until the owner claims it.

I'm not calling your project slop! Geeze, it's a joke... Do you own this repo?

Log in with GitHub as Turingwlen. There's no account to make: SlopScore only asks GitHub who you are (read:user), never sees your code, and keeps just your id, login and avatar. Then you can:

  • Keep it, on your terms. Commit your own slopscore.md (spec) and press Refresh. Your paperwork replaces the Cap'm's, and you can submit it for Slop of the Day.
  • Take it down. One click on Remove. It stays gone; the trawl never brings it back.

Log in with GitHub

Can't log in as the owner? Request a takedown. No login needed, and a trawled listing comes down right away.

GitHub says
a single-file + single-run python script to essentially audit large drives of torrent files. The idea is to help you understand what's on their drive, what's being seeded, what's not, duplicate files that can be replaced by hardlinks, and what's wasting space.
created
2026-07-25 · pushed 2 months ago · 5 commits · 1 contributor
languages
Python 100%
paperwork
licensereadme 42% health
dependencies
no dependency graph (no manifest, or disabled) · OSV.dev, checked 1 hour ago

Disclosures, inferred by the Cap'm

slopbucket
vibe-coded
category
other
ai_generated
mostly
human_touch
light
status
works-on-my-machine
language (detected)
python
license (detected)
unlicense

The Cap'm's log

The Cap'm wrote this paperwork, not the owner. This repo never submitted itself to SlopScore. The Cap'm picked it by hand: a single-file + single-run python script to essentially audit large drives of torrent files. The idea is to he; its own README says "AI Usage this script was written entirely by AI (Claude Sonnet + Opus)". It carries the Unlicense license. The disclosures above are his best guess from what GitHub shows.

Is this yours? Commit a real slopscore.md and press Refresh to replace this, or remove the listing in one click. There's no account to make: you log in with GitHub.

README — the repo's own words, folded up so the grading fits on one screen

disk.py

A comprehensive, single-file + single-run disk audit tool for large drives. Built for users managing lots of torrent content who need to understand what's on their drive, what's being seeded, what's not, duplicate files that can be replaced by hardlinks, and what's wasting space.

Zero dependencies beyond Python 3.9+ standard library. Optional xxhash for faster hashing.

AI Usage this script was written entirely by AI (Claude Sonnet + Opus). While I (mostly) know what I'm going, I've only done some minimal checking of functionality and a security pass. This script is meant for personal use and was not designed with lots of safeguards in mind. I cannot guarantee this script won't fuck up your drive layout, delete files, or some other miserable catastrophe. (Though it really shouldn't). Use at your own risk: I've used it and it has worked fine for me, but that's it. This readme has been fully verified and by me and is accurate.

What It Does

disk.py treats inodes as the fundamental unit. Every unique file on disk is an inode; every path to that file (including hardlinks) is tracked individually. For each path, it checks against qBittorrent's Web API whether that path is actively serving a torrent.

This answers questions like:

  • Which files are taking up the most space?
  • Does every hardlink actually serve a purpose, or are some orphaned?
  • Are there true duplicates (same content, different inodes) wasting disk space?
  • Which files aren't attached to any torrent?
  • How much space could I recover if I cleaned up unused files?

Quick Start

First-time setup (optional — only needed for qBittorrent/media features): copy .env.example to .env and fill in your settings. See Configuration.

cp .env.example .env    # then edit .env (qBittorrent URL/credentials, media dirs, …)

# Minimal — scans the drive, detects duplicates, offers to fix them
python3 disk.py /mnt/data

# With qBittorrent integration
python3 disk.py /mnt/data --qbt-url https://localhost:8080 --qbt-pass mypassword

# With a media library (Plex/Jellyfin) and multiple qBittorrent instances
python3 disk.py /mnt/data --media-dir /mnt/data/Media \
    --qbt-url https://localhost:8080 --qbt-pass mypassword \
    --qbt-instance "https://seedbox:8080|admin|otherpassword"

# Report only, no cleanup prompts
python3 disk.py /mnt/data --no-fix --no-cleanup-orphans --no-cleanup-empty

# Skip metadata files, flag stale content
python3 disk.py /mnt/data --ignore-ext nfo,txt,srt,jpg --stale-days 180

# mergerfs union: just point at it — the branches are discovered and scanned
python3 disk.py /mnt/pool --media-dir /mnt/pool/Media

# Scan two drives at once and find the same file living on both
python3 disk.py /mnt/disk1 /mnt/disk2

For repeated use, put your defaults in the .env file (see Configuration) so you can just run:

python3 disk.py /mnt/data

Output Files

All output is written next to disk.py itself (the script's own directory) — never inside the tree you scan. This keeps the scanned data untouched, lets you scan read-only or mergerfs-union mounts, and means re-runs share one hash cache. The first path you scanned is still recorded inside the reports as the "root" they describe. (Override the cache location with --hash-db; the one exception is --cross-seed, whose hardlink directory must live on the data filesystem — see Cross-Seed Directory.). The JSON Files are provided as extras for further machine consumption if you want to use them.

File Description
diskreport.log Full terminal output (ANSI codes stripped)
diskreport.html Interactive HTML report with sortable tables, search, and dark theme
used_inodes.json Inodes considered used (all paths serve an active torrent, or reclassified as used via --media-dir — see Media Directory Reclassification)
unused_inodes.json Inodes where no paths serve a torrent (and not kept by --media-dir)
mixed_inodes.json Inodes where some paths have torrents and some don't
duplicate_files.json True duplicate groups (same content, different inodes). Each group carries a cross_drive flag
cross_drive_duplicates.json Only the duplicate groups whose copies live on different drives — can't be hardlinked, so reclaimed by deleting a redundant copy (see Cross-Drive Duplicates)

HTML Report

The HTML report is a self-contained, zero-dependency file you can open in any browser. It's built as a triage workspace, not just a read-only dump — the idea is to open it, work through your files, and export an action list. It includes:

Viewing

  • Summary stat cards (inodes, real usage, used/unused with size, dup groups + wasted, cross-drive + reclaimable, stale) plus a live "reviewed X / Y" progress chip.
  • Content-type breakdown bar you can click to filter the tables to one or more types.
  • Tabbed views: Used / Unused / Mixed / Duplicates, plus a dedicated Cross-Drive tab when the same file is found on more than one drive.
  • Sortable columns (size, type, drive, links, last access, modified), a per-row Drive column, and a per-tab search box (searches paths, type, drive, inode) that keeps focus as you type.
  • Filters per tab: type, drive, torrent state, freshness (with --stale-days), min/max size, and review state.
  • Color-coded torrent status ([T]/[P]/[no torrent]), stale highlighting, and a ⇄ CROSS-DRIVE badge on cross-drive groups. A copy button on every path.

Triage

  • Every row has three one-click marks: ✓ keep, ✗ delete (candidate for removal), ⊘ hide (dismiss from view). Rows are tinted by mark.
  • Marks persist in your browser (localStorage), namespaced to the report's location — so you can close the tab, re-run/re-open the report later, and your keep/delete/hide decisions are still there. (If your browser blocks storage for file:// pages, a banner says so; marks still work for the session.)
  • Hidden rows drop out of view by default (toggle Show hidden to see them); the review-state filter also lets you show only Unreviewed / Keep / For-deletion / Hidden.
  • Bulk actions apply a mark to everything currently matching your filters (e.g. filter to "video, unused, > 5 GB, no torrent" then bulk-mark delete).
  • Bulk by pattern — a dedicated bar marks (keep/delete/hide/clear) every file matching a name/path pattern across the whole report in one action: *sample*, *.nfo, or /regex/, matched against the full path or just the file name, with a live match count and a confirm. This is the fast way to "exclude everything matching X". The per-tab search box understands the same *glob* and /regex/ syntax.
  • In the Cross-Drive tab, each copy shows its drive and a suggested keeper, with a one-click "keep this, delete others" per group.

Export (turn decisions into action)

  • From the Export menu: download a delete list (.txt of all paths marked delete), a delete script (.sh with rm lines, shell-quoted and disarmed with echo until you review it), a keep list, a marks backup (.json), or the current tab as CSV. You can also copy the delete paths to the clipboard.

Nothing in the HTML ever touches your files — it only records decisions and exports lists/scripts for you to run.

JSON Structure

Each inode entry in the JSON reports contains:

{
  "inode": 12345,
  "size_bytes": 1073741824,
  "size_human": "1.0 GB",
  "content_type": "video",
  "link_count": 2,
  "fs_link_count": 2,
  "drive": "/mnt/disk1",
  "mtime": "2025-06-15 14:30:00",
  "atime": "2026-01-10 08:15:00",
  "mtime_epoch": 1718458200.0,
  "atime_epoch": 1736496900.0,
  "stale": false,
  "paths": [
    {
      "path": "/mnt/data/torrents/Movie.mkv",
      "torrents": [
        {
          "torrent_name": "Movie.2024.1080p",
          "torrent_hash": "abc123...",
          "torrent_state": "uploading",
          "active": true
        }
      ],
      "has_torrent": true,
      "has_active_torrent": true
    },
    {
      "path": "/mnt/data/Media/Movies/Movie.mkv",
      "torrents": [],
      "has_torrent": false,
      "has_active_torrent": false
    }
  ]
}

Two link counts are reported, and they are not the same thing:

  • link_count — the number of paths (hardlinks) to this inode found inside the scanned root.
  • fs_link_count — the inode's true hardlink count on the filesystem (st_nlink), counting every link on the device, including any outside the scanned root. When fs_link_count is 1, the file has no other hardlinks anywhere; this is what allows a non-seeded file inside --media-dir to be treated as "used" (see Media Directory Reclassification).

Features

Inode-Centric File Model

Every unique file is identified by its inode. Multiple paths to the same inode (hardlinks) are listed individually, each with its own torrent status. This is essential for torrent setups where one file is hardlinked into both a torrent directory and a media library.

Internally, files are keyed by a collision-proof identity rather than the inode number alone — (device, inode) on an ordinary filesystem, or (branch, inode) on a mergerfs union (see below). This matters because the whole tool trusts "same inode ⇒ same file" when deciding what's safe to delete, and on a union that assumption can otherwise be violated.

mergerfs / union filesystems

If your storage is a mergerfs union of several disks, just point the tool at the union (e.g. /mnt/pool) — it makes itself aware of the disks underneath and does the right thing:

  • Branch discovery + scanning the real disks. On startup it reads the union's branch list (mergerfs' user.mergerfs.branches) and scans each underlying branch directly instead of the union. Scanning the real disks means inode identity, hardlink counts, and hardlink operations are all exact. The reports still land at the union path you gave.
  • No manual path config. It auto-derives the branch→union path mappings qBittorrent needs (qBittorrent reports union paths; the scan sees branch paths), and expands any union --media-dir (e.g. /mnt/pool/Media) to its per-branch equivalents. You don't have to hand-write --path-map or list every branch's media folder.
  • Why this is safety-critical. FUSE forces a single device id on the whole mount, and with inodecalc=passthrough mergerfs hands back the raw underlying inode. Two different files on two disks can then report the same (device, inode). A naive inode tool would treat them as hardlinks of one file and, during cleanup, delete one "path" believing the data survives via the other — destroying it. This tool resolves each file's real branch (via user.mergerfs.basepath) and keys by (branch, inode), so distinct files stay distinct. As an extra backstop, any inode whose in-scan path count exceeds its true st_nlink (impossible for a genuine hardlink group) is refused by every destructive phase. (mergerfs' own default inodecalc=hybrid-hash also avoids the collision; the tool is safe under any setting.)

Flags:

  • --no-mergerfs-expand — scan the union tree literally instead of expanding to branches (still safe, via per-file branch resolution).
  • --mergerfs-branches "/mnt/a,/mnt/b" — override auto-discovery (rarely needed).

Hardlinks can't cross mergerfs branches, so consolidating duplicates that live on different disks isn't a hardlink operation — see Cross-Drive Duplicates.

Scanning multiple locations

The path argument accepts more than one directory:

python3 disk.py /mnt/disk1 /mnt/disk2

Everything is scanned into one combined view, which is what lets the tool spot the same file living on two different drives. (Pointing at a mergerfs union does this for you by expanding to its branches.) Reports, log and cache are written next to disk.py (see Output Files); the first path is recorded inside them as the scanned root.

qBittorrent Integration

Connects to qBittorrent's Web API v2 to determine which files are part of active torrents. Works with self-signed HTTPS certificates (SSL verification disabled). Authentication uses session cookies.

Multiple instances: You can query more than one qBittorrent instance and the torrents from all of them are merged into a single pool — there is no differentiation by which instance a torrent came from. This is useful when you run separate instances (e.g. a local box and a seedbox) that seed content on the same drive. Instances come from any combination of:

  • the single --qbt-url / --qbt-user / --qbt-pass (or QBT_URL / QBT_USER / QBT_PASS in .env),
  • one or more --qbt-instance "url|user|pass" flags (repeatable; user and password are optional),
  • the QBT_INSTANCES key in .env (see Configuration).

Instances are de-duplicated by URL, so listing the same one twice is harmless. When the same torrent (identical infohash) is seeded on two instances, it is counted once; genuinely different torrents that happen to share a path are all kept. --insecure applies to every instance (a per-instance override is possible via the insecure key in a QBT_INSTANCES entry).

Connection failure handling: If any configured instance can't be reached, the script reports which ones failed and, in interactive mode, prompts you to continue (with the torrent data from the instances that did connect, or with none if all failed) or abort. In non-interactive mode (e.g. piped input or cron), it aborts automatically — a missing instance can make a seeded file look unused, which would be unsafe for the cleanup phases to act on.

Path mapping (Docker / mergerfs): qBittorrent often reports a torrent's files under a different path than the real filesystem — for example because it runs in a Docker container with bind mounts, or because it sees a mergerfs/union mount instead of the underlying branches. Without help, the script would compare its on-disk paths against qBittorrent's paths, find no match, and wrongly classify seeded files as unused (and potentially cross-seed them or flag their paths for removal).

Path mappings fix this. Each mapping translates an on-disk path prefix to the prefix qBittorrent reports. Suppose your files physically live under /mnt/disk1 and /mnt/disk2, but qBittorrent (in its container) sees everything under /mnt/pool:

python3 disk.py /mnt/data \
    --qbt-url https://localhost:8080 --qbt-pass mypassword \
    --path-map "/mnt/disk1|/mnt/pool" \
    --path-map "/mnt/disk2|/mnt/pool"

Now a scanned file /mnt/disk1/file.mkv is looked up as /mnt/pool/file.mkv, matches the torrent qBittorrent is seeding, and is correctly classified as used. Notes:

  • --path-map is repeatable, and several on-disk prefixes may map to the same qBittorrent prefix (as above).
  • When prefixes overlap, the longest (most specific) matching prefix is used.
  • The on-disk path is matched literally — a union/overlay prefix is not resolved to its underlying branch before mapping.
  • Mappings can also be set via PATH_MAPPINGS in .env (see Configuration); the .env value and the flags are merged.
  • The original (unmapped) path is still checked too, so torrents that qBittorrent happens to report with the real path keep matching.

Active vs. inactive torrents: By default, only torrents in an active state count as "used" — this includes uploading, stalledUP, downloading, stalledDL, checkingUP, checkingDL, queuedUP, queuedDL, forcedUP, forcedDL, moving, allocating, metaDL, and forcedMetaDL. Paused and errored torrents are treated as inactive.

Use --all-torrents to treat every torrent as active regardless of state.

The terminal report uses symbols to distinguish status:

  • [T] — path serves an active torrent
  • [P] — path serves a paused/inactive torrent
  • [·] — path has no torrent association

Inode Classification

Each inode is classified based on its paths' torrent coverage:

Category Meaning
Used All paths serve an active torrent
Unused No paths serve any torrent
Mixed Some paths have torrents, some don't

Media Directory Reclassification

The --media-dir option handles a common setup: a media library (e.g., for Plex/Jellyfin) living alongside your torrents. Files there often won't have direct torrent associations, which would otherwise classify them as "mixed" or "unused". With --media-dir set, two things are reclassified as used:

1. Hardlinked-into-media (mixed → used). When a file is hardlinked from a torrent directory into the media library, the torrent path seeds it and the media path serves it. Such a "mixed" inode is reclassified as "used" if and only if every non-torrent path is inside the media directory.

Key rules:

  • Only affects mixed inodes (must have at least one torrent path)
  • All non-torrent paths must be inside the media dir to reclassify

2. Standalone media files (unused → used). A file that lives only inside the media directory, has no other hardlinks anywhere (fs_link_count is 1), and isn't being seeded is treated as "used" rather than "unused". This is genuine, user-owned content — a personal rip, a manually-added file, etc. — that simply isn't attached to a torrent. Because it has no other hardlink, deleting it would lose the data, so it should not be flagged for cleanup or picked up by cross-seed.

Key rules:

  • Only affects fully unused inodes inside the media dir
  • Only when the file has no other hardlinks (fs_link_count == 1); an unused file that is hardlinked elsewhere (fs_link_count > 1) stays "unused"
  • On by default; pass --no-keep-unseeded-media to disable it and classify these files as "unused" instead. (This flag does not affect case 1.)

Both behaviors require --media-dir. Without it, classification is unchanged.

--media-dir is repeatable — pass it once per location if your library spans several folders or several disks (e.g. --media-dir /mnt/disk1/Media --media-dir /mnt/disk2/Media). A path counts as "in the media library" if it's inside any of them. (When you point at a mergerfs union, a single union media path is expanded to each branch for you.)

Two-Phase Duplicate Detection

Duplicates are files with identical content but different inodes — genuinely wasting disk space (unlike hardlinks, which share the same inode).

Detection uses a two-phase approach for speed:

  1. Partial hash — hash the first + last 8 KB of files with matching sizes (fast filter to eliminate most non-duplicates)
  2. Full hash — hash the entire content only for files that survived phase 1

Drive-aware full-hash scheduling. The full-hash phase is parallelised per drive, not by a flat thread count — because on a spinning disk, reading two files at once just makes the head seek back and forth and destroys throughput. So each spinning drive gets exactly one reader, while different drives are read in parallel (one reader each); as a drive runs out of work its reader exits, so concurrency falls automatically to a single reader once only one disk is left. This generalises to any number of drives (an N-HDD scan uses N readers). Drives detected as SSD/NVMe (non-rotational) aren't seek-bound and may use up to --workers readers each. --workers (default 4) is therefore the per-SSD cap; spinning disks ignore it and always use one reader. Set --workers 1 to force fully sequential. Rotational vs. solid-state is detected from the OS (/sys/.../queue/rotational); if it can't be determined, the drive is treated as spinning (the safe choice — one reader never thrashes).

Cross-Drive Duplicates

When you scan more than one drive (directly, or via a mergerfs union), the tool flags duplicate groups whose identical copies live on different drives. A hardlink can't span two filesystems, so the regular dedup phase can't touch these — and it no longer even prompts for them (a group whose copies are all on different drives is skipped in the dedup step, since "fixing" it would be a no-op). They're handled separately:

  • Every cross-drive group is listed in the terminal and written to cross_drive_duplicates.json (each copy's drive + reclaimable space), and shown with a ⇄ CROSS-DRIVE badge and a dedicated Cross-Drive tab in the HTML report. Reporting never changes anything on its own.
  • To reclaim the space, pass --consolidate-cross-drive. This does not just delete a copy (that would make the union path vanish and could break a torrent). Instead it migrates the redundant copy onto the kept copy's drive: for each of its paths it recreates the file as a hardlink to the kept inode at the same relative path on the keeper's branch, then removes the original. On a mergerfs union the union path (e.g. /mnt/pool/Data/x.mkv) is unchanged — qBittorrent/Plex see no difference — and the redundant physical copy is freed. So a file with 7 hardlinks on disk A plus a copy on disk B becomes one inode with 8 hardlinks on A (the 8th at B's old relative path), and B's copy is gone.
  • It always prompts per group (no --auto), suggests a keeper (seeded → in a media dir → most hardlinks), links before removing (the path is served throughout), never clobbers a different file already at the target, and respects --dry-run. If a scan isn't mergerfs (branches unknown), migration isn't possible and it falls back to deleting the redundant copy instead.
  • The HTML Cross-Drive tab can build the same thing without the CLI: each group has a "keep this → migration script" button (and a "Migration script (all, keep suggested)" button) that downloads a .sh of ln … && rm … commands — the && guard means a source is only removed if its new hardlink was created, so an existing target is never clobbered.

Hash Cache (SQLite)

On a large drive, hashing is the bottleneck. The hash cache stores computed hashes in a SQLite database keyed by (path, inode, size, mtime_ns). On re-runs, stat() (which is way faster than reading file contents) is used to check if a file has changed — if not, the cached hash is reused.

The cache uses WAL mode and per-thread connections for safe parallel access.

  • Default location: .disk_cache.db next to disk.py (the script's own directory), so re-runs share one cache regardless of what you scan
  • Override: --hash-db /path/to/cache.db
  • Disable: --no-cache
  • Force re-hash: --rehash (still updates the cache for next run)

Stale entries (files that no longer exist) are automatically pruned.

Each hash is committed as soon as it's computed (one small transaction per file), not batched at the end. So if a long hashing run is interrupted with Ctrl+C (or killed), every file already hashed is safely in the cache and won't be re-hashed next time — you only lose the file(s) actually being read at that instant (at most one per active reader). The next run picks up where it left off. This only applies with the cache enabled (the default); --no-cache saves nothing.

Interactive Deduplication

When duplicates are found, the script can consolidate them into hardlinks using an atomic rename-hardlink-unlink pattern:

  1. Rename the duplicate to a .bak file
  2. Create a hardlink to the kept inode
  3. Remove the .bak file

If the hardlink fails, the original is restored from .bak. If it works, every path that existed before still exists after — they just all point to the same inode now, freeing the duplicate blocks.

Modes:

  • Interactive (default): prompts per duplicate group — [y]es / [n]o / [a]ll remaining / [q]uit
  • Auto-fix (--auto-fix): fixes all without prompting
  • Dry run (--dry-run): shows what would happen without touching files
  • Skip (--no-fix): report only, no dedup

Content Type Breakdown

Files are categorized by extension into media types: video, audio, books, subtitle, image, metadata, and other. Both the terminal report and HTML report show a breakdown of disk usage per type, and each JSON entry includes a content_type field.

Age-Based Stale File Detection

Use --stale-days N to flag inodes that

Read the rest on GitHub

Scan report · 2026-09-26
  • ✓ Prohibited terms or links — profanity in body/README
  • ✓ Repository eligibility
  • ✓ slopscore.md paperwork
  • ✓ Content policy
  • ✓ Risk review — +10 owner has 0 followers

From the balcony · 0 of 4 clapped

    Crusoe, Schnitzel, Cap'm Slop and Princess read it and passed. Their reasons are on the balcony, with every other verdict.

    Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.

    0 comments

    log in to comment.

    report this listing — log in to report