#!/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 sorted order # (both sort into episode order for every case seen so far: qBittorrent's # torrent file listing and Sonarr's SxxExx-prefixed renamed files). Always # prints the proposed pairing before applying - check it makes sense, # especially for anything with specials/extras where sort order could # mismatch episode order. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$HOME/.claude-homelab/.env" 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 # Current files inside the torrent (relative paths, in qBittorrent's index order) old_files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH") torrent_name=$(python3 -c " import json,sys files = json.loads(sys.argv[1]) if not files: print('ERROR: no files returned for this hash - check the hash is correct', file=sys.stderr) sys.exit(1) print(files[0]['name'].split('/')[0]) " "$old_files_json") echo "Torrent folder: $torrent_name" echo "New folder: $NEW_FOLDER" echo # New files actually on disk at the destination (via any container with the nas_media mount) new_files=$(docker exec jellyfin sh -c "ls -1 \"$NEW_FOLDER\"" | sort) # Build the old->new pairing (sorted-order match), print it for review, and # save it as a TSV for the rename loop below. python3 -c " import json, sys old_files = sorted(json.loads(sys.argv[1]), key=lambda f: f['name']) new_files = sys.argv[2].strip().split(chr(10)) if sys.argv[2].strip() else [] if len(old_files) != len(new_files): print(f'ERROR: file count mismatch - torrent has {len(old_files)} files, destination has {len(new_files)}', file=sys.stderr) sys.exit(1) with open('/tmp/qbt_relink_flat.tsv', 'w') as f: for old, new in zip(old_files, new_files): print(f\" {old['name']}\") print(f\" -> {new}\") f.write(f\"{old['name']}\t{new}\n\") " "$old_files_json" "$new_files" echo echo "--- Applying ---" echo "setLocation -> $NEW_FOLDER" curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setLocation" \ --data-urlencode "hashes=$HASH" \ --data-urlencode "location=$NEW_FOLDER" > /dev/null # setLocation above already moved the torrent's root to $NEW_FOLDER itself # (the season folder) - so new paths here are bare filenames, not prefixed # with the season folder name again. while IFS=$'\t' read -r old new; do curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/renameFile" \ --data-urlencode "hash=$HASH" \ --data-urlencode "oldPath=$old" \ --data-urlencode "newPath=$new" > /dev/null echo " renamed: $old -> $new" done < /tmp/qbt_relink_flat.tsv echo "recheck..." curl -s -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/recheck" \ --data-urlencode "hashes=$HASH" > /dev/null echo "Done. Recheck is running in the background - poll with:" echo " bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info $HASH"