Safely migrate completed torrents — data + .torrent + seeding duty — from a remote
seedbox qBittorrent instance to a local one, using the Qui
API to drive both clients. The seedbox copy is deleted only after the local copy is
verified seeding. Single-file Python 3, standard library only, no pip install.
Built for the common homelab case: you keep long-term seeds on a home box (Unraid, Proxmox, a NAS) but grab on a seedbox, and want to move the seeding duty home without re-downloading over BitTorrent (which would re-report to the tracker and cost you ratio on private trackers).
Because on a private tracker the downloading client reports that download to the tracker, so a
plain BitTorrent re-download counts against your ratio all over again. seedbox-mover moves the
bytes out-of-band with rclone and only ever hands qBittorrent an already-complete copy, so
the tracker sees a seeder appear, not a leecher.
select (completed, in your categories, older than N days, not already handled)
│
├─ already on the local client at 100% ──► recheck → tracker-host match
│ → pause the seedbox copy → tag it
│ "mover-verified" (no transfer)
│
├─ same *content* already local (cross-seed twin) ──► hardlink it (one physical copy, both seed)
│
└─ otherwise ──► pause on seedbox
→ rclone pull the data (parallel streams)
→ add to the local client STOPPED, correct save path/category
→ force-recheck: must be 100% piece-hash verified (it cannot download
anything while stopped, so a bad transfer can never trigger a swarm
re-download)
→ resume → tag the seedbox copy "mover-verified"
The tag means the data is verified — 100% piece-hash on the matching tracker host. A live
working announce is deliberately not required to move (it's transient: a tracker or client
blip fails it even when the torrent is fine); the announce is fired-and-reported during the
move, and re-verified live at --delete, the only irreversible step.
Deletion is a separate, explicit step (--delete) that re-verifies every gate live — a
fresh on-disk re-hash, tracker-host match, and a working announce — immediately before it removes
the seedbox copy. Nothing is ever deleted on the strength of a stale tag.
- A deletion happens only after a live re-hash proves the local copy is 100% and a fresh announce is accepted by the tracker. A stale tag alone can never delete anything.
- Transfers land STOPPED and are piece-hash rechecked before they're allowed to seed — a corrupt or partial pull fails the gate and is left for you, never seeded, never swarm-completed.
rclone --ignore-existingmeans a pull can never overwrite a file another local torrent is already seeding.- Landing collisions auto-resolve. When the same album exists on two trackers with different encodes (identical folder name, different bytes), the incoming copy lands at a unique per-infohash path so both seed; byte-identical twins are hardlinked to one physical copy instead.
- Cross-seed twins are never orphaned. Deleting a torrent's files destroys the folder, so
every other torrent seeding from it dies too. A folder is only deleted once every torrent sharing
it has itself migrated and passed its own live gates; otherwise the delete is refused. Twins are
adopted during the move — re-registered against the already-migrated copy, zero bytes transferred
— and
--deletere-derives the check from live state rather than trusting the move pass. - Persistent failures are quarantined (logged, skipped) so they don't silently stall the queue.
- Exactly one copy seeds. The seedbox copy is paused before it is tagged, so the same torrent never seeds from both machines; on any failure the side left seeding is the one that actually holds verified data (local if verified-complete and active, else seedbox).
- Single instance at a time (flock); non-idempotent API calls are never blindly retried.
- One instance only ever deletes on the SEEDBOX side, and only its own tagged torrents.
- Rides out transient blips. A qbit/Qui restart or brief tracker outage mid-run doesn't
abort it: per-torrent failures fail closed and the run continues, GETs retry with backoff,
and tracker health never gates the move — a blip can't strand a verified transfer, because
the working-announce check lives at
--delete, where it is re-run live every time.
python3(3.8+), standard library only — nothing to install.- Qui managing both qBittorrent instances (local + seedbox), reachable over HTTP with an API key.
rclonewith a remote pointing at the seedbox's torrent storage (SFTP/FTP/etc.).- Run it where it can read and hardlink the local client's torrent data — usually the same host, LXC, or container as the local qBittorrent.
- Configure — edit the
USER CONFIGURATIONblock at the top ofseedbox-mover.py(instances, categories, paths, rclone remote — see the config reference below). - API key — in Qui: Settings → API Keys → Create. Put the key (and nothing else) in a file:
(or export
printf %s 'YOUR_QUI_API_KEY' > api.key && chmod 600 api.key
SEEDBOX_MOVER_API_KEYinstead). - rclone remote — e.g.
rclone config create seedbox sftp host=... user=... pass=... - Dry-run — run with no flags; it reports what it would do and changes nothing.
python3 seedbox-mover.py # dry-run: report only, touches nothing
python3 seedbox-mover.py --run # migrate + verify up to --max torrents; tags them,
# deletes nothing
python3 seedbox-mover.py --delete # delete seedbox torrents tagged mover-verified
# (every gate re-verified live first)
python3 seedbox-mover.py --delete --dry-run-delete # preview what --delete would remove
python3 seedbox-mover.py --flagged # read-only: list quarantined / failed / unhealthy
# torrents with disposition hints
python3 seedbox-mover.py --clear-quarantine # un-quarantine (all, or --hashes) and retry
python3 seedbox-mover.py --recover-missing # report seedbox torrents stuck in
# missingFiles and how they'd be recovered
python3 seedbox-mover.py --recover-missing --run # re-register them against local dataFlags: --max N caps torrents per run (move-pass default 10; --delete defaults to all tagged;
--max 0 = none). --hashes H1,H2 restricts to specific infohashes.
Typical rhythm: --run → review the mover-verified tags in Qui → --delete. It's
cron-friendly (schedule --run, keep --delete supervised until you trust it); only one instance
runs at a time.
Cancelling a long run: send SIGTERM/SIGINT (Ctrl-C, or pkill -f seedbox-mover). It
finishes the torrent it's on — completing its gates or failing closed — then exits at the next
boundary, never mid-transfer. SIGKILL (-9) also stops safely but leaves one partial pull for
the next run to resume.
All settings live in the USER CONFIGURATION block at the top of the script. The important ones:
| Setting | What it is |
|---|---|
QUI / API_KEY_FILE |
Qui base URL and the file holding your API key |
LOCAL_INSTANCE / SEEDBOX_INSTANCE |
the two instance names exactly as shown in Qui |
CATEGORY_MAP |
{seedbox_category: local_landing_category} — which categories to migrate and where they land (must be dedicated seed categories, not *arr's) |
VERIFY_ONLY_CATEGORIES |
categories handled only when the same torrent already exists locally at 100% (verify → pause → tag, never transferred) — for cross-seed twin categories (e.g. {"cross-seed-link"}) that span trackers/content outside what you migrate |
ADOPT_CROSS_SEED_TWINS |
when a torrent shares its folder with a cross-seed twin, migrate the twin with it (zero transfer — same bytes). False leaves such folders on the seedbox permanently, since they can never be safely file-deleted |
RCLONE_REMOTE / REMOTE_STRIP_PREFIX |
rclone remote name, and the prefix stripped from a seedbox content_path to get the path relative to that remote |
QBIT_DATA / HOST_DATA |
the torrent-data root as the local qbit sees it (container path) vs. as this script sees it (host path); equal if qbit runs natively |
FUSE_ROOT / PHYS_MOUNTS |
for hardlinking across a union/multi-disk filesystem. Single disk/dataset: set both to the same real root (Unraid uses /mnt/user + the array disks; ZFS across datasets lists each) |
CHOWN |
"uid:gid" to own pulled files, or "" to rely on umask |
COLLISION_RESOLVE / QUARANTINE_PARTIAL_LOCAL |
how persistent failures and same-name collisions are handled |
Setup profiles (Unraid, Proxmox/ZFS, plain Docker, single disk) are described inline in the config comments.
- The mover and Qui's own cross-seed engine can both import the same cross-tracker twins; they cooperate (the mover detects a mid-run import and steps aside), but you may see it defer work to cross-seed.
- If a seedbox torrent's root folder was renamed in qBittorrent so it differs from the
.torrent's metainfo name, that torrent fails recheck and is quarantined (fail-closed). Fix the name on the seedbox and re-run. - Not affiliated with Qui/autobrr or qBittorrent. This is a community script that uses their public APIs.
MIT — see LICENSE.