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
This commit is contained in:
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/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."
|
||||
Reference in New Issue
Block a user