Files
docker-infrastructure/qbt-relink-daily.sh
T
poprhythm 86b1610e05 Add qbt-relink-daily.sh for single-file (daily show) torrents
qbt-relink.sh assumes torrent file count matches the destination
folder's file count 1:1 - true for season packs, false for a daily
show like Jeopardy where each torrent holds one episode but the
destination Season <year> folder holds every episode that aired
that year. This matches by date parsed from the torrent's own
filename against the single dated file in that season folder,
using the same safety model (single blocking pass, pre/post
filesystem size check, idempotent, no repeated manual triggering).

Verified against a live torrent - clean single-pass relink,
post-check confirmed the file intact.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 12:52:50 +00:00

207 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Re-point a SINGLE-FILE qBittorrent torrent (one episode of a daily/dated
# show, e.g. Jeopardy) at its Sonarr-organized destination file.
#
# 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 <year> folder holds every
# episode that aired that year. This script matches by date instead, parsed
# from the torrent's own original filename (YYYY.MM.DD or YYYY-MM-DD) against
# the single file in that season folder containing the same date.
#
# Usage:
# ./qbt-relink-daily.sh <torrent-hash> <show-root-folder>
# ./qbt-relink-daily.sh bd6c2778... "/data/video/tv/Jeopardy! (1984)"
#
# Same safety model as qbt-relink.sh: one blocking pass (stop -> locate ->
# rename -> recheck -> poll -> verify), pre/post-flight size check against
# the filesystem (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 # 20 min - generous for a single file over NFS
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")
date_key=$(python3 -c "
import re, sys
m = re.search(r'(\d{4})[.\-](\d{2})[.\-](\d{2})', sys.argv[1])
if not m:
print('ERROR: could not parse a YYYY.MM.DD or YYYY-MM-DD date from the filename', file=sys.stderr)
sys.exit(1)
print(f'{m.group(1)}-{m.group(2)}-{m.group(3)}')
" "$old_name")
year="${date_key:0:4}"
season_folder="$SHOW_ROOT/Season $year"
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\"" | grep -F "$date_key" || true)
match_count=$(echo -n "$matches" | grep -c . || true)
if [[ "$match_count" -ne 1 ]]; then
echo "Error: expected exactly 1 file matching date $date_key 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 date $date_key -> $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."