Add qbt-relink-episode.sh for single-file standard-episode torrents

Third shape variant: qbt-relink.sh handles season packs (N files
matching N files), qbt-relink-daily.sh handles single-file
date-based episodes (Jeopardy). This handles single-file torrents
carrying one standard SxxEyy episode (e.g. Lower Decks, downloaded
one file per torrent rather than as season packs) - matches by
parsed season/episode number against the single file in that
Season NN folder carrying the same numbers. Same safety model
throughout: one blocking pass, pre/post filesystem size check
independent of qBittorrent's self-reported state, idempotent.

Verified against a live torrent - clean single-pass relink.

Claude-Session: https://claude.ai/code/session_01KjrQwEec4vzukbnHiFpFmk
This commit is contained in:
2026-09-10 02:39:17 +00:00
parent 7c65401b2c
commit 7531bd698b
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Re-point a SINGLE-FILE qBittorrent torrent holding one standard SxxEyy
# episode (not a date-based daily show - see qbt-relink-daily.sh for that)
# at its Sonarr-organized destination file.
#
# Same shape problem as qbt-relink-daily.sh: qbt-relink.sh assumes the
# torrent's file count matches the destination FOLDER's file count 1:1,
# true for season packs, false here - one torrent holds one episode, but
# the destination Season NN folder holds every episode in that season.
# Matches by SxxEyy parsed from the torrent's own original filename against
# the single file with that season/episode in the destination Season NN
# folder.
#
# Usage:
# ./qbt-relink-episode.sh <torrent-hash> <show-root-folder>
# ./qbt-relink-episode.sh ce7c0f5e... "/data/video/tv/Star Trek - Lower Decks (2020)"
#
# Same safety model as qbt-relink.sh/qbt-relink-daily.sh: one blocking pass,
# pre/post-flight filesystem size check (not qBittorrent's self-reported
# state), idempotent, no repeated manual triggering against the same hash.
set -euo pipefail
ENV_FILE="$HOME/.claude-homelab/.env"
POLL_INTERVAL=15
POLL_TIMEOUT_S=1200
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found" >&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:-}"
SHOW_ROOT="${2:-}"
if [[ -z "$HASH" || -z "$SHOW_ROOT" ]]; then
echo "Usage: $0 <torrent-hash> <show-root-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() {
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
}
torrent_info() {
curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/info" --data-urlencode "hashes=$HASH"
}
wait_for_path_field() {
local field="$1" expected="$2" match_mode="$3"
local attempt actual
for attempt in $(seq 1 15); do
actual=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['$field'])")
if [[ "$match_mode" == "prefix" && "$actual" == "$expected"* ]]; then
echo "$actual"; return 0
elif [[ "$match_mode" == "exact" && "$actual" == "$expected" ]]; then
echo "$actual"; return 0
fi
sleep 1
done
echo "$actual"
return 1
}
echo "=== Pre-flight ==="
files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
file_count=$(python3 -c "import json,sys; print(len(json.loads(sys.argv[1])))" "$files_json")
if [[ "$file_count" != "1" ]]; then
echo "Error: this script is for single-file torrents only (found $file_count files). Use qbt-relink.sh instead." >&2
exit 1
fi
old_name=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])[0]['name'])" "$files_json")
ep_key=$(python3 -c "
import re, sys
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', sys.argv[1])
if not m:
print('ERROR: could not parse SxxEyy from the filename', file=sys.stderr)
sys.exit(1)
print(f'{int(m.group(1)):02d} {int(m.group(2)):02d}')
" "$old_name")
season_num="${ep_key%% *}"
ep_num="${ep_key##* }"
season_folder="$SHOW_ROOT/Season $season_num"
if ! docker exec jellyfin sh -c "[ -d \"$season_folder\" ]"; then
echo "Error: season folder does not exist: $season_folder" >&2
exit 1
fi
matches=$(docker exec jellyfin sh -c "ls -1 \"$season_folder\"" | python3 -c "
import re, sys
target_s, target_e = int('$season_num'), int('$ep_num')
for line in sys.stdin:
line = line.rstrip()
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', line)
if m and int(m.group(1)) == target_s and int(m.group(2)) == target_e:
print(line)
")
match_count=$(echo -n "$matches" | grep -c . || true)
if [[ "$match_count" -ne 1 ]]; then
echo "Error: expected exactly 1 file matching S${season_num}E${ep_num} in $season_folder, found $match_count:" >&2
echo "$matches" >&2
exit 1
fi
target_filename="$matches"
target_path="$season_folder/$target_filename"
target_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"")
echo "Torrent file: $old_name"
echo "Matched S${season_num}E${ep_num} -> $target_filename ($target_size bytes)"
# --- Idempotency check ---
current_info=$(torrent_info)
current_content_path=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['content_path'])")
if [[ "$current_content_path" == "$season_folder/$target_filename" ]]; then
current_progress=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['progress'])")
file_progress=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH" | python3 -c "import json,sys; print(json.load(sys.stdin)[0].get('progress',0))")
if python3 -c "exit(0 if float('$file_progress') >= 0.999 else 1)"; then
echo "Already relinked and verified (progress ~$current_progress). Nothing to do."
exit 0
fi
echo "content_path matches but not yet fully verified (progress: $current_progress) - proceeding."
fi
echo
echo "=== Stopping torrent ==="
api_call "stop" "torrents/stop" --data-urlencode "hashes=$HASH"
echo
echo "=== Applying ==="
echo "setLocation -> $season_folder"
api_call "setLocation" "torrents/setLocation" \
--data-urlencode "hashes=$HASH" \
--data-urlencode "location=$season_folder"
if ! actual_path=$(wait_for_path_field "save_path" "$season_folder" exact); then
echo "Error: setLocation did not take effect after 15s (save_path is still '$actual_path')." >&2
exit 1
fi
echo " confirmed: save_path is now $actual_path"
echo "setDownloadPath -> $season_folder (clears any stale incomplete-path override)"
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=$season_folder")
if [[ "$status" != "200" && "$status" != "400" ]]; then
echo "Error: setDownloadPath failed (http $status)" >&2
exit 1
fi
if ! content_path=$(wait_for_path_field "content_path" "$season_folder" prefix); then
echo "Error: content_path is '$content_path', not under '$season_folder' after 15s." >&2
exit 1
fi
echo " confirmed: content_path is now $content_path"
api_call "renameFile" "torrents/renameFile" \
--data-urlencode "hash=$HASH" \
--data-urlencode "oldPath=$old_name" \
--data-urlencode "newPath=$target_filename"
echo " renamed: $old_name -> $target_filename"
echo
echo "=== Rechecking (triggered exactly once) ==="
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
elapsed=0
final_state=""
while [[ "$elapsed" -lt "$POLL_TIMEOUT_S" ]]; do
sleep "$POLL_INTERVAL"
elapsed=$((elapsed + POLL_INTERVAL))
state=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['state'])")
if [[ "$state" != "checkingUP" && "$state" != "checkingDL" && "$state" != "checkingResumeData" ]]; then
final_state="$state"
break
fi
done
if [[ -z "$final_state" ]]; then
echo "TIMED OUT after ${POLL_TIMEOUT_S}s. Do not re-trigger by hand - re-run this exact invocation later (idempotent)." >&2
exit 1
fi
echo " settled: $final_state"
echo
echo "=== Post-check ==="
final_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"" 2>/dev/null || echo "MISSING")
if [[ "$final_size" != "$target_size" ]]; then
echo "CRITICAL: $target_path size changed or vanished ($target_size -> $final_size). Torrent left STOPPED. Investigate by hand." >&2
exit 1
fi
echo "File intact ($final_size bytes matches pre-flight). No data loss detected."
echo "Done. Torrent left STOPPED - start it from the WebUI once satisfied."