#!/usr/bin/env bash # Re-point a qBittorrent torrent at files Sonarr has since renamed/moved. # # When Sonarr imports an existing download via hardlink, it removes the file # from its original torrent-named folder (only the new hardlinked copy # remains) - so qBittorrent's record of that torrent silently starts # pointing at nothing. This re-links the torrent to the new location so it # keeps seeding the same underlying data (same inode, so the hash check # passes) instead of erroring out as "missing files". # # Usage: # ./qbt-relink.sh # # should be the Sonarr season folder (or show folder, for a # single-season show) containing the renamed files - e.g. # ./qbt-relink.sh 8601b9b9... "/data/video/tv/Andor (2022)/Season 02" # # Pairs the torrent's files with the files in by SxxEyy episode # number parsed out of each filename (falls back to alphabetical sort only if # a name can't be parsed, with a loud warning - alphabetical sort silently # mismatches on non-zero-padded episode numbers, e.g. "E9" sorts after "E10"). # Always prints the proposed pairing before applying - check it makes sense, # especially for anything with specials/extras where a torrent's files span # more than one destination folder (this script only handles one folder; # see the sonarr skill doc for that case). # # SAFETY MODEL (rewritten after a real data-loss incident - see the sonarr # skill doc, "Incident 3"): # # - The whole operation runs as ONE blocking pass: stop -> relocate -> rename # -> recheck -> poll to completion -> verify -> report. It does NOT return # control with "poll manually and come back" - that pattern is exactly what # caused the incident, because re-running recheck/stop by hand later, after # qBittorrent's own state had moved on, raced against qBittorrent's # automatic incomplete-file management and let it destroy real files. # NEVER call recheck/stop/setLocation against the same hash a second time # while a previous invocation might still be settling - wait for this # script to finish (or clearly fail) first. # - Before touching qBittorrent at all, it snapshots the destination folder's # filenames+sizes (the ground truth - Sonarr already put the real files # there). After the recheck settles, it re-reads the folder and compares. # Any file that shrank, vanished, or changed size is treated as data loss, # reported loudly, and the script does NOT attempt any further remediation # (no retry, no re-trigger) - investigate by hand from a known-bad state # rather than risk compounding it. # - If the torrent already looks correctly relinked (content_path matches and # on-disk files match the expected sizes), the script does nothing further # - idempotent, so re-running it after a partial failure is safe rather # than repeating destructive steps. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$HOME/.claude-homelab/.env" POLL_INTERVAL=15 POLL_TIMEOUT_S=2400 # 40 min - generous for large multi-GB files over NFS if [[ ! -f "$ENV_FILE" ]]; then echo "Error: $ENV_FILE not found (qBittorrent creds live there, not this repo's .credentials)" >&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:-}" NEW_FOLDER="${2:-}" if [[ -z "$HASH" || -z "$NEW_FOLDER" ]]; 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