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