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:
Executable
+216
@@ -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."
|
||||
Reference in New Issue
Block a user