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.
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?
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/disk2For repeated use, put your defaults in the .env file (see Configuration) so you can just run:
python3 disk.py /mnt/dataAll 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) |
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-DRIVEbadge 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 (
.txtof all paths marked delete), a delete script (.shwithrmlines, shell-quoted and disarmed withechountil 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.
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. Whenfs_link_countis1, the file has no other hardlinks anywhere; this is what allows a non-seeded file inside--media-dirto be treated as "used" (see Media Directory Reclassification).
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.
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-mapor list every branch's media folder. - Why this is safety-critical. FUSE forces a single device id on the whole mount, and with
inodecalc=passthroughmergerfs 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 (viauser.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 truest_nlink(impossible for a genuine hardlink group) is refused by every destructive phase. (mergerfs' own defaultinodecalc=hybrid-hashalso 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.
The path argument accepts more than one directory:
python3 disk.py /mnt/disk1 /mnt/disk2Everything 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.
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(orQBT_URL/QBT_USER/QBT_PASSin.env), - one or more
--qbt-instance "url|user|pass"flags (repeatable; user and password are optional), - the
QBT_INSTANCESkey 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-mapis 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_MAPPINGSin.env(see Configuration); the.envvalue 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
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 |
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-mediato 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.)
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:
- Partial hash — hash the first + last 8 KB of files with matching sizes (fast filter to eliminate most non-duplicates)
- 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).
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-DRIVEbadge 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
.shofln … && rm …commands — the&&guard means a source is only removed if its new hardlink was created, so an existing target is never clobbered.
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.dbnext todisk.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.
When duplicates are found, the script can consolidate them into hardlinks using an atomic rename-hardlink-unlink pattern:
- Rename the duplicate to a
.bakfile - Create a hardlink to the kept inode
- Remove the
.bakfile
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
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.
Use --stale-days N to flag inodes that
0 comments
log in to comment.