#!/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."