From 86b1610e058bd9e87c73444a8e72dab98d2bb578 Mon Sep 17 00:00:00 2001 From: poprhythm Date: Wed, 9 Sep 2026 12:52:50 +0000 Subject: [PATCH] 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 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 --- qbt-relink-daily.sh | 206 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100755 qbt-relink-daily.sh diff --git a/qbt-relink-daily.sh b/qbt-relink-daily.sh new file mode 100755 index 0000000..eb001a1 --- /dev/null +++ b/qbt-relink-daily.sh @@ -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 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 +# ./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 " >&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."