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:
@@ -64,15 +64,18 @@ bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/sc
|
|||||||
```
|
```
|
||||||
|
|
||||||
It **stops the torrent first** (qBittorrent 5.x renamed pause/resume to
|
It **stops the torrent first** (qBittorrent 5.x renamed pause/resume to
|
||||||
stop/start), sets its location to the season folder — verifying the change
|
stop/start), sets its location *and* download-path override to the season
|
||||||
actually took effect before continuing, not just trusting a 200 response —
|
folder — verifying both actually took effect before continuing, not just
|
||||||
renames each file to match Sonarr's output (paired by sorted order — always
|
trusting a 200 response — pairs and renames each file to match Sonarr's
|
||||||
check the printed preview makes sense), then triggers a recheck, and leaves
|
output (by parsed `SxxEyy` episode number, not sort order — see below), then
|
||||||
the torrent **stopped** afterward rather than auto-resuming. Since it's the
|
triggers a recheck, and leaves the torrent **stopped** afterward rather than
|
||||||
same underlying data (hardlink, same inode), the hash check passes and the
|
auto-resuming. Since it's the same underlying data (hardlink, same inode),
|
||||||
torrent is ready to seed normally again once you manually start it — no
|
the hash check passes and the torrent is ready to seed normally again once
|
||||||
re-download needed. **The recheck reads the whole file over NFS and is
|
you manually start it — no re-download needed. **The recheck reads the whole
|
||||||
slow** (minutes per multi-GB file) — kick off several in parallel rather than
|
file over NFS and is slow** (minutes per multi-GB file, and this qBittorrent
|
||||||
|
instance seems to only actively process one or two full-file rechecks at a
|
||||||
|
time — others sit at `stoppedDL`/0% "queued" looking identical to a real
|
||||||
|
failure until their turn comes) — kick off several in parallel rather than
|
||||||
waiting on each one serially, and poll with:
|
waiting on each one serially, and poll with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -90,6 +93,43 @@ 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.
|
||||||
|
|
||||||
|
**Known limitation**: if Sonarr split a single torrent's files across two
|
||||||
|
destination folders (e.g. a season pack that included specials, which Sonarr
|
||||||
|
routed to a separate `Specials`/`Season 00` folder), `qbt-relink.sh` can't
|
||||||
|
handle that in one call — it only knows about one `<new-folder>`. Check the
|
||||||
|
file count first (`torrents/files` count vs. `ls` count in the season
|
||||||
|
folder); if they don't match because of a specials split, this needs doing
|
||||||
|
by hand (or extending the tool) rather than forcing it.
|
||||||
|
|
||||||
|
### Incident 2: a stale `download_path` override kept recheck pointed at the wrong place entirely
|
||||||
|
|
||||||
|
Even after incident 1's fix (checked HTTP status, confirmed `save_path`
|
||||||
|
via a follow-up GET), three of the nine torrents in the same batch
|
||||||
|
(Babylon 5 S03/S04/S05) still failed — `setLocation` genuinely succeeded and
|
||||||
|
`save_path` genuinely updated, but their **`content_path`** (what qBittorrent
|
||||||
|
actually reads during a recheck) stayed pointed at
|
||||||
|
`/data/torrents/incomplete/video/tv`, qBittorrent's global incomplete-files
|
||||||
|
staging path (`temp_path`, from `app/preferences`). Cause: earlier in the
|
||||||
|
same incident, these three torrents had briefly been flagged as needing to
|
||||||
|
download again (a race between the bad first relink attempt and this
|
||||||
|
corrective one — see the qBittorrent log via `/api/v2/log/main` for
|
||||||
|
`"Torrent move canceled"` / duplicate `"Start moving torrent"` entries if
|
||||||
|
this happens again), which set a **per-torrent `download_path` override**
|
||||||
|
that `setLocation` does not clear. `content_path` silently followed that
|
||||||
|
stale override instead of `save_path` — so the recheck kept validating
|
||||||
|
against an empty folder and would have re-downloaded again, exactly like
|
||||||
|
incident 1, had it not been caught by watching state directly rather than
|
||||||
|
trusting the script's "Done" message.
|
||||||
|
|
||||||
|
Fix: `torrents/setDownloadPath` (note — takes `id`, not `hashes`, unlike
|
||||||
|
every other endpoint used here) clears the override, and `qbt-relink.sh` now
|
||||||
|
calls it and verifies `content_path` (not just `save_path`) actually landed
|
||||||
|
under the target folder before proceeding to rename/recheck. **If a torrent
|
||||||
|
ever seems stuck at `stoppedDL`/0% far longer than others in the same batch,
|
||||||
|
or its `state` is `checkingDL` instead of `checkingUP`**, check
|
||||||
|
`content_path` vs `save_path` directly — a mismatch means the recheck is
|
||||||
|
running against the wrong location and will "succeed" at finding nothing.
|
||||||
|
|
||||||
### Incident: an unchecked `setLocation` call let a torrent start re-downloading
|
### Incident: an unchecked `setLocation` call let a torrent start re-downloading
|
||||||
|
|
||||||
The first version of `qbt-relink.sh` fired `setLocation`/`renameFile`/
|
The first version of `qbt-relink.sh` fired `setLocation`/`renameFile`/
|
||||||
|
|||||||
+65
-14
@@ -15,12 +15,14 @@
|
|||||||
# single-season show) containing the renamed files - e.g.
|
# single-season show) containing the renamed files - e.g.
|
||||||
# ./qbt-relink.sh 8601b9b9... "/data/video/tv/Andor (2022)/Season 02"
|
# ./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
|
# Pairs the torrent's files with the files in <new-folder> by SxxEyy episode
|
||||||
# (both sort into episode order for every case seen so far: qBittorrent's
|
# number parsed out of each filename (falls back to alphabetical sort only if
|
||||||
# torrent file listing and Sonarr's SxxExx-prefixed renamed files). Always
|
# a name can't be parsed, with a loud warning - alphabetical sort silently
|
||||||
# prints the proposed pairing before applying - check it makes sense,
|
# mismatches on non-zero-padded episode numbers, e.g. "E9" sorts after "E10").
|
||||||
# especially for anything with specials/extras where sort order could
|
# Always prints the proposed pairing before applying - check it makes sense,
|
||||||
# mismatch episode order.
|
# 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
|
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 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)
|
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
|
# Build the old->new pairing, preferring an explicit SxxEyy match over sort
|
||||||
# save it as a TSV for the rename loop below.
|
# order (see header comment for why sort order alone is unsafe).
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import json, sys
|
import json, re, sys
|
||||||
old_files = sorted(json.loads(sys.argv[1]), key=lambda f: f['name'])
|
|
||||||
|
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 []
|
new_files = sys.argv[2].strip().split(chr(10)) if sys.argv[2].strip() else []
|
||||||
|
|
||||||
if len(old_files) != len(new_files):
|
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)
|
print(f'ERROR: file count mismatch - torrent has {len(old_files)} files, destination has {len(new_files)}', file=sys.stderr)
|
||||||
sys.exit(1)
|
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:
|
with open('/tmp/qbt_relink_flat.tsv', 'w') as f:
|
||||||
for old, new in zip(old_files, new_files):
|
for old, new in pairs:
|
||||||
print(f\" {old['name']}\")
|
print(f' {old}')
|
||||||
print(f\" -> {new}\")
|
print(f' -> {new}')
|
||||||
f.write(f\"{old['name']}\t{new}\n\")
|
f.write(f'{old}\t{new}\n')
|
||||||
" "$old_files_json" "$new_files"
|
" "$old_files_json" "$new_files"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -137,6 +161,33 @@ if [[ "$actual_path" != "$NEW_FOLDER" ]]; then
|
|||||||
fi
|
fi
|
||||||
echo " confirmed: save_path is now $actual_path"
|
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
|
# 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user