Dedupearr

Dedupearr

Docker app from chrizantoz's Repository

Overview

Dedupearr: the truth about your storage. For every video file under your media and download folders it tells you what the file is - the real copy, a hardlink that costs nothing, a second copy that wastes space, a download nothing in the library matches, or an old recycle-bin entry - with the evidence in one plain sentence. It also asks your download clients (Deluge, NZBGet, qBittorrent) and the arrs, so a download becomes a leftover, an orphan or kept-for-seeding, and your seeding rule says what can go now. Read-only by default: fixes show their steps as a dry run until you switch them on. Companion to Auditarr.

💾 Dedupearr

Dedupearr - the truth about your storage

CI release licence runs on demo image

Dedupearr tells you the truth about your storage. If you run Sonarr, Radarr and a torrent client, your download folder is supposed to share its files with your library through hardlinks (two names for one file on disk), so seeding costs no extra space. One wrong path, one copy instead of a link, one upgrade that left the old torrent behind, and the same movie sits on your disk twice. Dedupearr walks every video file each night and says what it is, with the evidence, in one plain sentence.

Think of it as the companion to Auditarr: Auditarr asks is this file good?, Dedupearr asks is this file real and needed?

  • 🧪 Try it in your browser: dedupearr-demo.chrizantoz.com is the real app on a fake disk with one of every situation; every button works and it resets on the hour. A static copy of the page lives on the project site.
  • 📘 New here? Start with the User guide: a plain-language tour of the page and every button.
  • 📋 Every verdict it gives and every button it has: FEATURES.md
  • 📰 What changed and when: CHANGELOG.md
  • 🗺️ Where it is going: ROADMAP.md · 📦 Installing step by step: docs/INSTALL.md

It is deliberately small: one Python file, one HTML page, a local SQLite file for its state, and no separate database server. It runs as a single Docker container.

🚧 Status

Phase 2 of 4, still read-only. Dedupearr looks and tells; it moves, links and deletes nothing. It was built on one homelab (about 15,000 files and 38 TB across Sonarr, Radarr and qBittorrent on Unraid) and its first real scan already found 370 GB of second copies. The next phases add the download-client and arr readers, then the fixes behind a dry run and a 14-day undo. The roadmap has the detail.

Phase What it does Status
1 · Scan and tell Disk scan (inode, link count, size, checksum), verdicts, Files and Space pages, nightly scan done
2 · Ask the apps Deluge, NZBGet, qBittorrent and the arrs' import history: a download becomes leftover, orphan or seeding only, with your seeding rule saying what can go now this release (rTorrent, Transmission, SABnzbd to come)
3 · Fix safely Relink, remove the old torrent, empty the bin: dry run first, holding bin, 14-day undo after 2
4 · Easy to install Setup wizard with the shared-mount test, Unraid cache/array awareness, Unraid Apps, public demo after 3

🖼️ Screenshots

Taken by the test bot in CI from the fake disk, so they are always current.

Files: one verdict per file, with the evidence Space: where the gigabytes went
Files tab Space tab
About: health, settings, every scan On a phone
About tab Phone layout

The page follows your system theme; a light version is one setting away.

🧭 How it works

Every night Dedupearr reads the disk (inode, link count, size, checksum), groups the names that share one file, compares the rest, and gives each file one verdict with its evidence: real copy, stored twice, download without a match, old recycle-bin file.

Every night it records every video file's inode, link count and size. Names that share one inode are one file, so a library file and its seeding torrent that are hardlinked cost nothing and are marked Real copy. A download that has the same size as a library file is compared by checksum: identical means Stored twice, identical except the first megabyte means Stored twice, header differs (a language tag was edited on one copy), different means Looks alike. A download nothing matches is a Download without a match until phase 2 asks the torrent client about it. Recycle-bin files past your rule are Old recycle-bin files. Every verdict comes with one plain sentence and one line of evidence.

🧰 What you'll need

  • Sonarr and/or Radarr with a torrent client, writing to a folder Dedupearr can read. Nothing is connected in phase 1; it only reads the disk.
  • Your library and your download folder visible through one mount (/data below), the way the arr guides set it up. If they are two mounts, a hardlink between them cannot exist and Dedupearr cannot see that two names are one file.
  • About two minutes to fill in the docker run command below.

Try it without installing

dedupearr-demo.chrizantoz.com runs the real app on the fake disk below, on its own network, and rebuilds itself every hour. sandbox/run.sh up starts the same playground on your machine.

Try it without a library

python3 tests/fake_disk.py /tmp/dedupearr-fake
CONFIG_DIR=/tmp/dedupearr-cfg SCAN_ROOTS=/tmp/dedupearr-fake MIN_SIZE_MB=1 python3 dedupearr.py --scan

The fake disk holds one of every situation: a real hardlink, an identical second copy, a copy whose header was edited, an unmatched download, a lookalike, an old and a recent recycle-bin file, two names inside the library, and an unsorted folder.

How it works in three steps

  1. 🔗 Mount the share that holds your library and your downloads, read-only, and start the container.
  2. 🌙 It scans every night (and once at start): 15,000 files take under a minute. Checksums run only on files that look like copies and are remembered, so a file is hashed once in its life.
  3. 👀 You read one sentence per file: Stored twice: the library file Dune Part Two 2024 and this download are two separate files with identical content. Making them one file again would free 48.2 GB. In phase 1 you act on it yourself; phase 3 adds the button.

What it runs on

Inside the container: Python 3.12 and its standard library, nothing else. No database server, no framework. The test bots need Node 20 with jsdom and playwright (see uibot/package.json); none of that ships in the image.

🚀 Quick start (Docker)

docker run -d --name dedupearr --restart unless-stopped -p 3092:3092 \
  -e TZ=America/Winnipeg -v /path/to/appdata/dedupearr:/config -v /path/to/media:/data:ro \
  ghcr.io/chrizantoz/dedupearr:latest

Open http://host:3092. The first scan starts on its own; after that it runs every night at SCAN_TIME (04:30 by default).

Step-by-step for Unraid and docker compose: docs/INSTALL.md. A compose file is in docs/docker-compose.yml, an Unraid template in docs/unraid-template.xml.

📁 Requirements: the library and the download folders must be inside the one mount (/data here). Dedupearr tells library from downloads from recycle bins by folder names (media, movies, tv, torrents, downloads, .trash, .recycle, …); when your names differ, say so with LIBRARY_DIRS, DOWNLOAD_DIRS and RECYCLE_DIRS.

💬 Help and community

🔒 Who can open the page

Out of the box the page is open to whoever can reach the port, which at home means your local network. On the About tab, Protect the page sets a user and password (stored hashed in Dedupearr's own database; do it from your own network) and from then on the page asks for a sign-in: a plain login page, a signed session cookie, Sign out in the top bar; a new password signs every browser out. /health stays open for monitoring. You can also set AUTH_USER and AUTH_PASSWORD on the container: they win over the page, which is also the way back in if you forget the password.

  • No sign-in at home, sign-in through your domain: tick No sign-in on my own network under the password (or set LOCAL_NO_LOGIN=1). A browser on your own network (or tailnet) that opens the address and port directly gets straight in; anything that arrives through a reverse proxy, tunnel or CDN (it adds X-Forwarded-For or a similar header) is always asked, even though the proxy itself sits on your network. Leave it off if your router forwards the port straight to Dedupearr: some routers make every outside visitor look like they are at home.
  • Already behind Authelia or Authentik: TRUST_PROXY_AUTH=1 lets their Remote-User header count as the sign-in, so you log in once.

🛡️ Is it safe? (yes: phase 1 changes nothing)

  • Read-only, by construction. The container mounts your media read-only (:ro) and the code has no code path that moves, links or deletes a file. The banner on the page says so.
  • Verdicts need evidence. A hardlink is a shared inode across the library and the download folder. "Stored twice" needs identical content, confirmed by checksum. "Looks alike" is a same-size file whose content differs, and it is never called waste.
  • When the fixes come (phase 3) they follow Auditarr's rules: dry run on by default, files move to a holding bin instead of being deleted, 14 days of one-click undo, and a verdict that is unsure gets no button.

⚙️ Settings

Phase 1 reads its settings from the environment; the About tab shows them (never a key or a password: there are none). A settings page comes with phase 2.

Setting Default Meaning
SCAN_ROOTS /data Comma-separated folders to scan
LIBRARY_DIRS, DOWNLOAD_DIRS, RECYCLE_DIRS empty Explicit folders when the names below do not fit
LIBRARY_HINTS media,library,movies,tv,shows,series,anime,… Folder names that mean "library"
DOWNLOAD_HINTS torrents,downloads,complete,incomplete,usenet,… Folder names that mean "downloads"
RECYCLE_HINTS .recycle,.trash,recycle,trash,#recycle,… Folder names that mean "recycle bin"
RECYCLE_DAYS 30 Bin files older than this are waste
MIN_SIZE_MB 50 Smaller files are not judged
HASH_BUDGET_MB 20000 Full checksums per scan; beyond it duplicates are called "very likely" from samples
SCAN_TIME 04:30 Nightly scan time (container TZ)
SONARR_URL, SONARR_APIKEY (also RADARR_*, WHISPARR_*, SPORTARR_*, LIDARR_*) empty Lets Dedupearr read the arr's import history: which download became which library file
DELUGE_URL, DELUGE_PASSWORD empty Deluge web address (http://host:8112) and its web password
NZBGET_URL, NZBGET_USER, NZBGET_PASSWORD empty NZBGet address (http://host:6789) and its control login
QBIT_URL, QBIT_USER, QBIT_PASSWORD empty qBittorrent web address and login
FIX_MODE, HOLDING_DIR, HOLD_DAYS dryrun, <first root>/.dedupearr-holding, 14 What a fix may do: off, dryrun (show the steps, change nothing) or on (arrives fix by fix in phase 3); where moved files wait and for how long before deletion
SEED_MIN_RATIO, SEED_MIN_DAYS, SEED_RULE 1.0, 14, both Your seeding rule: a torrent is "done" when the ratio and the days are reached (both, safe for private trackers) or when one of them is (either); then a leftover or second copy "can go now"
CLIENT_PATH_MAP empty clientpath=ourpath,… when a client sees the files under another path than Dedupearr does
AUTH_USER, AUTH_PASSWORD empty Turn the sign-in on from the container (wins over the password set on the About tab)
LOCAL_NO_LOGIN empty empty = the About tab decides; 1 = no sign-in for a browser on your own network talking to the port directly; through a domain it still asks
TRUST_PROXY_AUTH 0 1 = a reverse proxy's Remote-User header counts as signed in
PORT 3092 Web port

🧑‍💻 For developers

🧪 Testing (full write-up: docs/TESTING.md): a fake disk with every situation, verdict and plain-language tests, an API contract test that records the UI fixtures, a random-disk fuzzer, chaos, mutation and performance checks, a jsdom UI bot on five edge worlds, a data fuzzer, bot lints and a real-browser bot with screenshots. CI runs all of it on every push and publishes the image only when it is green.

🗂️ Project layout

dedupearr.py    the whole backend (scanner, verdicts, scheduler, HTTP API)
ui.html         the whole UI
tests/          fake disk, verdict / plain-language / API / fuzz / chaos / mutation / perf tests
uibot/          headless UI bot, data fuzzer, edge worlds, real-browser bot, lints, fixtures
docs/           install and user guides, Unraid template, compose file, banner generator

Built for a single homelab; shared as-is. Issues and ideas welcome.

☕ Support

Dedupearr is free and stays free. If it gave you a terabyte back, a tip helps pay for the test hardware behind the next release.

GitHub Sponsors Ko-fi

📄 License

GPL-3.0. Use it, change it, share it; anything you distribute based on it must stay open under the same terms. See LICENSE.

Requirements

Mount the library and the download folders through ONE path (/data below), as the arr guides recommend; otherwise hardlinks cannot be seen.

Related apps

Details

Repository
ghcr.io/chrizantoz/dedupearr:latest
Last Updated2026-11-12
First Seen2026-11-12

Runtime arguments

Web UI
http://[IP]:[PORT:3092]/
Network
bridge
Shell
sh
Privileged
false

Template configuration

Web portPorttcp
Target
3092
Default
3092
Value
3092
ConfigPathrw

database and logs

Target
/config
Default
/mnt/user/appdata/dedupearr
Value
/mnt/user/appdata/dedupearr
Data (library + downloads, read-only)Pathro

one share that holds both the library and the download folders

Target
/data
Default
/mnt/user/media
Value
/mnt/user/media
SCAN_ROOTSVariable

comma-separated folders to scan (inside /data)

Default
/data
Value
/data
RECYCLE_DAYSVariable

recycle-bin files older than this are waste

Default
30
Value
30
SCAN_TIMEVariable

nightly scan time (container TZ)

Default
04:30
Value
04:30
SONARR_URLVariable

e.g. http://192.168.1.10:8989 (lets Dedupearr read Sonarr's import history)

SONARR_APIKEYVariable

Sonarr → Settings → General → Security → API Key

RADARR_URLVariable

e.g. http://192.168.1.10:7878

RADARR_APIKEYVariable
DELUGE_URLVariable

Deluge web address, e.g. http://192.168.1.10:8112

DELUGE_PASSWORDVariable

Deluge web password

QBIT_URLVariable

qBittorrent web address, e.g. http://192.168.1.10:8080

QBIT_USERVariable
QBIT_PASSWORDVariable
NZBGET_URLVariable

NZBGet address, e.g. http://192.168.1.10:6789

NZBGET_USERVariable
NZBGET_PASSWORDVariable
SEED_MIN_RATIOVariable

your seeding rule: ratio a torrent must reach

Default
1.0
Value
1.0
SEED_MIN_DAYSVariable

your seeding rule: days a torrent must seed

Default
14
Value
14
SEED_RULEVariable

both = ratio and days (private trackers), either = one of them is enough

Default
both
Value
both
FIX_MODEVariable

off, dryrun (show the steps, change nothing) or on

Default
dryrun
Value
dryrun
HASH_BUDGET_MBVariable

full checksums per scan; beyond it duplicates are called 'very likely' from samples

Default
20000
Value
20000
TZVariable
Default
America/Winnipeg
Value
America/Winnipeg