in qbt-relink.sh Second live incident in the same batch: setLocation genuinely succeeded and save_path updated correctly, but content_path (what recheck actually reads) stayed pointed at qBittorrent's incomplete staging path via a leftover per-torrent download_path override from an earlier failed attempt. setLocation doesn't clear that override. Fixed by also calling torrents/setDownloadPath (note: takes "id", not "hashes") and verifying content_path directly before proceeding. Also replaced alphabetical-sort file pairing with SxxEyy-parsed matching, since sort order silently breaks on non-zero-padded episode numbers (E9 sorts after E10) - a real risk across the ~320 remaining folders with inconsistent naming conventions. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
211 lines
9.6 KiB
Bash
Executable File
211 lines
9.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 SxxEyy episode
|
|
# number parsed out of each filename (falls back to alphabetical sort only if
|
|
# a name can't be parsed, with a loud warning - alphabetical sort silently
|
|
# mismatches on non-zero-padded episode numbers, e.g. "E9" sorts after "E10").
|
|
# Always prints the proposed pairing before applying - check it makes sense,
|
|
# especially for anything with specials/extras where a torrent's files span
|
|
# more than one destination folder (this script only handles one folder;
|
|
# see the sonarr skill doc for that case).
|
|
|
|
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, preferring an explicit SxxEyy match over sort
|
|
# order (see header comment for why sort order alone is unsafe).
|
|
python3 -c "
|
|
import json, re, sys
|
|
|
|
def ep_key(name):
|
|
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', name)
|
|
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
|
|
old_files = json.loads(sys.argv[1])
|
|
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)
|
|
|
|
old_keys = {f['name']: ep_key(f['name']) for f in old_files}
|
|
new_keys = {n: ep_key(n) for n in new_files}
|
|
|
|
if all(old_keys.values()) and all(new_keys.values()) and len(set(old_keys.values())) == len(old_files) and len(set(new_keys.values())) == len(new_files):
|
|
old_by_key = {v: k for k, v in old_keys.items()}
|
|
new_by_key = {v: k for k, v in new_keys.items()}
|
|
common = set(old_by_key) & set(new_by_key)
|
|
if common != set(old_by_key) or common != set(new_by_key):
|
|
print('ERROR: episode-number sets differ between torrent and destination - not safe to auto-pair. Check both manually.', file=sys.stderr)
|
|
sys.exit(1)
|
|
pairs = [(old_by_key[k], new_by_key[k]) for k in sorted(common)]
|
|
else:
|
|
print('WARNING: could not parse a unique SxxEyy from every filename - falling back to alphabetical-sort pairing.', file=sys.stderr)
|
|
print(' Double-check the pairing below carefully before it applies (this is exactly the failure mode', file=sys.stderr)
|
|
print(' that silently mismatches non-zero-padded episode numbers like E9 vs E10).', file=sys.stderr)
|
|
pairs = list(zip(sorted(old_keys), sorted(new_keys)))
|
|
|
|
with open('/tmp/qbt_relink_flat.tsv', 'w') as f:
|
|
for old, new in pairs:
|
|
print(f' {old}')
|
|
print(f' -> {new}')
|
|
f.write(f'{old}\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"
|
|
|
|
# If qBittorrent's "Keep incomplete torrents in" feature (temp_path_enabled)
|
|
# is on, and this torrent was ever flagged as needing to download (e.g. an
|
|
# earlier relink attempt failed and its recheck found 0% at the wrong path),
|
|
# it can carry a separate download_path override that content_path actually
|
|
# follows instead of save_path - setLocation alone does NOT clear this. This
|
|
# bit Babylon 5 S03/S04/S05 in the same incident: recheck kept running
|
|
# against the stale incomplete-staging path even after setLocation reported
|
|
# success. Note: this endpoint takes "id", not "hashes" (undocumented
|
|
# inconsistency vs. every other torrents/* endpoint here).
|
|
echo "setDownloadPath -> $NEW_FOLDER (in case a stale download_path override exists)"
|
|
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setDownloadPath" \
|
|
--data-urlencode "id=$HASH" --data-urlencode "path=$NEW_FOLDER")
|
|
# 400 here just means the torrent has no download_path override to clear
|
|
# (the common case) - only treat other failures as fatal.
|
|
if [[ "$status" != "200" && "$status" != "400" ]]; then
|
|
echo "Error: setDownloadPath failed (http $status)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
content_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]['content_path'])")
|
|
if [[ "$content_path" != "$NEW_FOLDER"* ]]; then
|
|
echo "Error: content_path is '$content_path', not under '$NEW_FOLDER'. A recheck would silently fail against the wrong path - not proceeding." >&2
|
|
exit 1
|
|
fi
|
|
echo " confirmed: content_path is now $content_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)."
|