Fix stale download_path override and unsafe alphabetical pairing

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
This commit is contained in:
2026-09-08 03:17:12 +00:00
parent f2ac2e5c53
commit 027108b492
2 changed files with 114 additions and 23 deletions
+65 -14
View File
@@ -15,12 +15,14 @@
# 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.
# 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
@@ -100,22 +102,44 @@ 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.
# 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, sys
old_files = sorted(json.loads(sys.argv[1]), key=lambda f: f['name'])
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 zip(old_files, new_files):
print(f\" {old['name']}\")
print(f\" -> {new}\")
f.write(f\"{old['name']}\t{new}\n\")
for old, new in pairs:
print(f' {old}')
print(f' -> {new}')
f.write(f'{old}\t{new}\n')
" "$old_files_json" "$new_files"
echo
@@ -137,6 +161,33 @@ if [[ "$actual_path" != "$NEW_FOLDER" ]]; then
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.