diff --git a/qbt-relink-episode.sh b/qbt-relink-episode.sh new file mode 100755 index 0000000..a4f4962 --- /dev/null +++ b/qbt-relink-episode.sh @@ -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 +# ./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 " >&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."