Harden qbt-relink.sh after a live incident: unchecked setLocation
let a torrent start re-downloading Batch-relinking 9 torrents, one setLocation call silently failed (status ignored) while the script declared success and moved on. Its recheck then ran against the original (now-empty) path, found 0% match, and qBittorrent started re-downloading the whole torrent from scratch into its incomplete-files staging area. No lasting harm (separate path from the real hardlinked copy, cleaned up), but caught only by watching qBittorrent directly, not by anything the script reported. Fixes: every mutating call now goes through an api_call() helper that checks the HTTP status and aborts on failure; the torrent is stopped before any location/rename calls (qBittorrent 5.x renamed pause/resume to stop/start) and left stopped after recheck rather than auto-resuming, so a bad relink can never turn into an active download. setLocation's effect is also verified via a follow-up GET before proceeding to renameFile. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
This commit is contained in:
@@ -63,24 +63,59 @@ bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/sc
|
|||||||
| python3 -c "import sys,json; [print(t['hash'], t['name']) for t in json.load(sys.stdin)]" | grep -i "<show name>"
|
| python3 -c "import sys,json; [print(t['hash'], t['name']) for t in json.load(sys.stdin)]" | grep -i "<show name>"
|
||||||
```
|
```
|
||||||
|
|
||||||
It sets the torrent's location to the season folder, renames each file to
|
It **stops the torrent first** (qBittorrent 5.x renamed pause/resume to
|
||||||
match Sonarr's output (paired by sorted order — always check the printed
|
stop/start), sets its location to the season folder — verifying the change
|
||||||
preview makes sense), then triggers a recheck. Since it's the same underlying
|
actually took effect before continuing, not just trusting a 200 response —
|
||||||
data (hardlink, same inode), the hash check passes and the torrent goes back
|
renames each file to match Sonarr's output (paired by sorted order — always
|
||||||
to seeding normally instead of erroring — no re-download. **The recheck reads
|
check the printed preview makes sense), then triggers a recheck, and leaves
|
||||||
the whole file over NFS and is slow** (minutes per multi-GB file) — kick off
|
the torrent **stopped** afterward rather than auto-resuming. Since it's the
|
||||||
several in parallel rather than waiting on each one serially, and poll with:
|
same underlying data (hardlink, same inode), the hash check passes and the
|
||||||
|
torrent is ready to seed normally again once you manually start it — no
|
||||||
|
re-download needed. **The recheck reads the whole file over NFS and is
|
||||||
|
slow** (minutes per multi-GB file) — kick off several in parallel rather than
|
||||||
|
waiting on each one serially, and poll with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info <hash>
|
bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info <hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Once a torrent shows 100% progress / `stoppedUP` (not `stoppedDL` — that
|
||||||
|
means the recheck found missing or mismatched pieces), start it again from
|
||||||
|
the WebUI. **Never assume a relink succeeded without checking** — see the
|
||||||
|
incident below.
|
||||||
|
|
||||||
For a multi-season show downloaded as separate per-season torrents (e.g.
|
For a multi-season show downloaded as separate per-season torrents (e.g.
|
||||||
Babylon 5), run `qbt-relink.sh` once per season/torrent, pointing each at its
|
Babylon 5), run `qbt-relink.sh` once per season/torrent, pointing each at its
|
||||||
own `Season NN` folder — don't try to relink multiple torrents to one shared
|
own `Season NN` folder — don't try to relink multiple torrents to one shared
|
||||||
show-root folder, since the tool matches file counts 1:1 between the torrent
|
show-root folder, since the tool matches file counts 1:1 between the torrent
|
||||||
and the destination folder.
|
and the destination folder.
|
||||||
|
|
||||||
|
### Incident: an unchecked `setLocation` call let a torrent start re-downloading
|
||||||
|
|
||||||
|
The first version of `qbt-relink.sh` fired `setLocation`/`renameFile`/
|
||||||
|
`recheck` with `curl -s ... > /dev/null`, discarding the HTTP status. Running
|
||||||
|
it across 9 torrents in a batch, one `setLocation` call silently failed (the
|
||||||
|
torrent still hadn't been re-pointed) while the script declared success
|
||||||
|
anyway. Its recheck then ran against the original path — already empty,
|
||||||
|
since Sonarr's import had removed the file — found 0% of pieces present, and
|
||||||
|
qBittorrent started **re-downloading the entire torrent from scratch** into
|
||||||
|
qBittorrent's default incomplete-files staging path
|
||||||
|
(`/data/torrents/incomplete/...`), racking up several minutes of real
|
||||||
|
download traffic before it was caught and stopped.
|
||||||
|
|
||||||
|
No actual harm resulted — the fresh download went to a separate staging path
|
||||||
|
and never touched the Sonarr-organized hardlinked copy (confirmed by
|
||||||
|
checking file counts and Plex/disk state directly), and the stray partial
|
||||||
|
files were deleted — but it could have gone worse (wasted bandwidth on a
|
||||||
|
private tracker, or worse, if the download target had somehow overlapped
|
||||||
|
with the real file). Root cause: no HTTP status checking, and the torrent
|
||||||
|
was never stopped before being touched. Both are now fixed in the script
|
||||||
|
(the `api_call` helper checks every response; the torrent is stopped before
|
||||||
|
any location/rename calls and stays stopped after recheck) — but the lesson
|
||||||
|
generalizes: **when scripting a batch of mutating API calls, verify each one
|
||||||
|
actually took effect before moving to the next, especially anything that
|
||||||
|
could make a client start writing data on its own.**
|
||||||
|
|
||||||
Credentials (`QBITTORRENT_URL`/`USERNAME`/`PASSWORD`) live in
|
Credentials (`QBITTORRENT_URL`/`USERNAME`/`PASSWORD`) live in
|
||||||
`~/.claude-homelab/.env` (the claude-homelab plugin's credential file), not
|
`~/.claude-homelab/.env` (the claude-homelab plugin's credential file), not
|
||||||
this repo's `.credentials` — `qbt-relink.sh` sources that file directly.
|
this repo's `.credentials` — `qbt-relink.sh` sources that file directly.
|
||||||
|
|||||||
+45
-7
@@ -57,6 +57,30 @@ if [[ "$login_status" != "200" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# api_call <label> <path> [curl data args...]
|
||||||
|
# Every write call goes through this - checks the HTTP status explicitly
|
||||||
|
# instead of trusting a silent curl call, which is exactly how a stalled
|
||||||
|
# torrent went back to actively downloading (a failed setLocation was never
|
||||||
|
# checked, so the script declared success and moved on anyway).
|
||||||
|
api_call() {
|
||||||
|
local label="$1" path="$2"
|
||||||
|
shift 2
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/$path" "$@")
|
||||||
|
if [[ "$status" != "200" ]]; then
|
||||||
|
echo "Error: $label failed (http $status)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop the torrent FIRST, before touching its location/files at all. A
|
||||||
|
# recheck runs fine on a stopped torrent, and this guarantees qBittorrent
|
||||||
|
# can never decide to start downloading missing pieces mid-relink - the
|
||||||
|
# failure mode that hit Babylon 5 S05. (qBittorrent 5.x renamed pause/resume
|
||||||
|
# to stop/start; the old /pause endpoint 404s silently on this version.)
|
||||||
|
echo "Stopping torrent (safety - no download can start while relinking)..."
|
||||||
|
api_call "stop" "torrents/stop" --data-urlencode "hashes=$HASH"
|
||||||
|
|
||||||
# Current files inside the torrent (relative paths, in qBittorrent's index order)
|
# Current files inside the torrent (relative paths, in qBittorrent's index order)
|
||||||
old_files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
|
old_files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
|
||||||
|
|
||||||
@@ -98,24 +122,38 @@ echo
|
|||||||
echo "--- Applying ---"
|
echo "--- Applying ---"
|
||||||
|
|
||||||
echo "setLocation -> $NEW_FOLDER"
|
echo "setLocation -> $NEW_FOLDER"
|
||||||
curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setLocation" \
|
api_call "setLocation" "torrents/setLocation" \
|
||||||
--data-urlencode "hashes=$HASH" \
|
--data-urlencode "hashes=$HASH" \
|
||||||
--data-urlencode "location=$NEW_FOLDER" > /dev/null
|
--data-urlencode "location=$NEW_FOLDER"
|
||||||
|
|
||||||
|
# Don't just trust the 200 - confirm the torrent's save_path actually changed
|
||||||
|
# before touching anything else. This is exactly the check that was missing
|
||||||
|
# when Babylon 5 S05 silently kept its old location and started re-downloading.
|
||||||
|
actual_path=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/info" --data-urlencode "hashes=$HASH" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['save_path'])")
|
||||||
|
if [[ "$actual_path" != "$NEW_FOLDER" ]]; then
|
||||||
|
echo "Error: setLocation did not take effect (save_path is still '$actual_path'). Torrent is stopped - not proceeding." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " confirmed: save_path is now $actual_path"
|
||||||
|
|
||||||
# setLocation above already moved the torrent's root to $NEW_FOLDER itself
|
# setLocation above already moved the torrent's root to $NEW_FOLDER itself
|
||||||
# (the season folder) - so new paths here are bare filenames, not prefixed
|
# (the season folder) - so new paths here are bare filenames, not prefixed
|
||||||
# with the season folder name again.
|
# with the season folder name again.
|
||||||
while IFS=$'\t' read -r old new; do
|
while IFS=$'\t' read -r old new; do
|
||||||
curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/renameFile" \
|
api_call "renameFile" "torrents/renameFile" \
|
||||||
--data-urlencode "hash=$HASH" \
|
--data-urlencode "hash=$HASH" \
|
||||||
--data-urlencode "oldPath=$old" \
|
--data-urlencode "oldPath=$old" \
|
||||||
--data-urlencode "newPath=$new" > /dev/null
|
--data-urlencode "newPath=$new"
|
||||||
echo " renamed: $old -> $new"
|
echo " renamed: $old -> $new"
|
||||||
done < /tmp/qbt_relink_flat.tsv
|
done < /tmp/qbt_relink_flat.tsv
|
||||||
|
|
||||||
echo "recheck..."
|
echo "recheck..."
|
||||||
curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/recheck" \
|
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
|
||||||
--data-urlencode "hashes=$HASH" > /dev/null
|
|
||||||
|
|
||||||
echo "Done. Recheck is running in the background - poll with:"
|
echo "Done. Torrent is left STOPPED so nothing can auto-resume downloading"
|
||||||
|
echo "before you've verified the recheck passed. Poll with:"
|
||||||
echo " bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info $HASH"
|
echo " bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info $HASH"
|
||||||
|
echo "Once it shows 100% progress / stoppedUP (not stoppedDL - stoppedDL means the"
|
||||||
|
echo "recheck found missing/mismatched pieces, do not resume), start it again from"
|
||||||
|
echo "the qBittorrent WebUI (this script does not auto-resume, on purpose)."
|
||||||
|
|||||||
Reference in New Issue
Block a user