Files
docker-infrastructure/qbt-relink.sh
T
poprhythm fddd462ff3 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
2026-09-08 03:01:22 +00:00

160 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Re-point a qBittorrent torrent at files Sonarr has since renamed/moved.
#
# When Sonarr imports an existing download via hardlink, it removes the file
# from its original torrent-named folder (only the new hardlinked copy
# remains) - so qBittorrent's record of that torrent silently starts
# pointing at nothing. This re-links the torrent to the new location so it
# keeps seeding the same underlying data (same inode, so the hash check
# passes) instead of erroring out as "missing files".
#
# Usage:
# ./qbt-relink.sh <torrent-hash> <new-folder>
#
# <new-folder> should be the Sonarr season folder (or show folder, for a
# single-season show) containing the renamed files - e.g.
# ./qbt-relink.sh 8601b9b9... "/data/video/tv/Andor (2022)/Season 02"
#
# Pairs the torrent's files with the files in <new-folder> by sorted order
# (both sort into episode order for every case seen so far: qBittorrent's
# torrent file listing and Sonarr's SxxExx-prefixed renamed files). Always
# prints the proposed pairing before applying - check it makes sense,
# especially for anything with specials/extras where sort order could
# mismatch episode order.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$HOME/.claude-homelab/.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found (qBittorrent creds live there, not this repo's .credentials)" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
if [[ -z "${QBITTORRENT_URL:-}" || -z "${QBITTORRENT_USERNAME:-}" || -z "${QBITTORRENT_PASSWORD:-}" ]]; then
echo "Error: QBITTORRENT_URL/USERNAME/PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
HASH="${1:-}"
NEW_FOLDER="${2:-}"
if [[ -z "$HASH" || -z "$NEW_FOLDER" ]]; then
echo "Usage: $0 <torrent-hash> <new-folder>" >&2
exit 1
fi
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
login_status=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/auth/login" \
--data-urlencode "username=$QBITTORRENT_USERNAME" \
--data-urlencode "password=$QBITTORRENT_PASSWORD")
if [[ "$login_status" != "200" ]]; then
echo "Error: qBittorrent login failed (http $login_status)" >&2
exit 1
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)
old_files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
torrent_name=$(python3 -c "
import json,sys
files = json.loads(sys.argv[1])
if not files:
print('ERROR: no files returned for this hash - check the hash is correct', file=sys.stderr)
sys.exit(1)
print(files[0]['name'].split('/')[0])
" "$old_files_json")
echo "Torrent folder: $torrent_name"
echo "New folder: $NEW_FOLDER"
echo
# New files actually on disk at the destination (via any container with the nas_media mount)
new_files=$(docker exec jellyfin sh -c "ls -1 \"$NEW_FOLDER\"" | sort)
# Build the old->new pairing (sorted-order match), print it for review, and
# save it as a TSV for the rename loop below.
python3 -c "
import json, sys
old_files = sorted(json.loads(sys.argv[1]), key=lambda f: f['name'])
new_files = sys.argv[2].strip().split(chr(10)) if sys.argv[2].strip() else []
if len(old_files) != len(new_files):
print(f'ERROR: file count mismatch - torrent has {len(old_files)} files, destination has {len(new_files)}', file=sys.stderr)
sys.exit(1)
with open('/tmp/qbt_relink_flat.tsv', 'w') as f:
for old, new in zip(old_files, new_files):
print(f\" {old['name']}\")
print(f\" -> {new}\")
f.write(f\"{old['name']}\t{new}\n\")
" "$old_files_json" "$new_files"
echo
echo "--- Applying ---"
echo "setLocation -> $NEW_FOLDER"
api_call "setLocation" "torrents/setLocation" \
--data-urlencode "hashes=$HASH" \
--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
# (the season folder) - so new paths here are bare filenames, not prefixed
# with the season folder name again.
while IFS=$'\t' read -r old new; do
api_call "renameFile" "torrents/renameFile" \
--data-urlencode "hash=$HASH" \
--data-urlencode "oldPath=$old" \
--data-urlencode "newPath=$new"
echo " renamed: $old -> $new"
done < /tmp/qbt_relink_flat.tsv
echo "recheck..."
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
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 "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)."