From 2b652796736e62e25cbebc2d375e2b912cab35c4 Mon Sep 17 00:00:00 2001 From: poprhythm Date: Tue, 8 Sep 2026 02:55:14 +0000 Subject: [PATCH] Add qbt-relink.sh to fix qBittorrent after a Sonarr import Sonarr's hardlink-import removes files from their original torrent-named folder, but qBittorrent's own records still point there - the next recheck or peer request flips those downloads to "missing files". qbt-relink.sh re-points a torrent's location and per-file names at the Sonarr-organized destination and triggers a recheck, so seeding continues against the same underlying data (same inode) instead of erroring out or needing a re-download. Documented as a required step in the sonarr skill's per-show migration workflow. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA --- .claude/skills/sonarr/SKILL.md | 48 ++++++++++++- qbt-relink.sh | 121 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100755 qbt-relink.sh diff --git a/.claude/skills/sonarr/SKILL.md b/.claude/skills/sonarr/SKILL.md index 56b8aa1..d2dc244 100644 --- a/.claude/skills/sonarr/SKILL.md +++ b/.claude/skills/sonarr/SKILL.md @@ -37,7 +37,53 @@ once. 5. **Clean up the empty source folder**: `docker exec jellyfin rmdir ""` (any container with the `nas_media` mount works). -6. **Verify Plex picks it up correctly** — this is not automatic, see below. +6. **Re-point qBittorrent at the new location** — not automatic, see below. + Skip only if the show was never actually downloaded via qBittorrent (rare). +7. **Verify Plex picks it up correctly** — also not automatic, see below. + +## Re-pointing qBittorrent after an import + +Sonarr's hardlink-import removes the file from its original torrent-named +folder (only the new hardlinked copy remains) — qBittorrent's own record of +that download doesn't know this happened and silently keeps pointing at the +now-empty original path. It won't show an error immediately (a `stalledUP` +torrent doesn't re-read its files until asked to), but the next recheck or +peer request will flip it to a `missingFiles` error state. + +Fix it with `./qbt-relink.sh ` (repo root): + +```bash +./qbt-relink.sh 8601b9b9a7164ff5038aa1f8e678e3e708eea845 "/data/video/tv/Andor (2022)/Season 02" +``` + +Find the hash first: +```bash +source ~/.claude-homelab/.env +bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh list \ + | python3 -c "import sys,json; [print(t['hash'], t['name']) for t in json.load(sys.stdin)]" | grep -i "" +``` + +It sets the torrent's location to the season folder, renames each file to +match Sonarr's output (paired by sorted order — always check the printed +preview makes sense), then triggers a recheck. Since it's the same underlying +data (hardlink, same inode), the hash check passes and the torrent goes back +to seeding normally instead of erroring — no re-download. **The recheck reads +the whole file over NFS and is slow** (minutes per multi-GB file) — kick off +several in parallel rather than waiting on each one serially, and poll with: + +```bash +bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info +``` + +For a multi-season show downloaded as separate per-season torrents (e.g. +Babylon 5), run `qbt-relink.sh` once per season/torrent, pointing each at its +own `Season NN` folder — don't try to relink multiple torrents to one shared +show-root folder, since the tool matches file counts 1:1 between the torrent +and the destination folder. + +Credentials (`QBITTORRENT_URL`/`USERNAME`/`PASSWORD`) live in +`~/.claude-homelab/.env` (the claude-homelab plugin's credential file), not +this repo's `.credentials` — `qbt-relink.sh` sources that file directly. ## Verifying the Plex side (do this every time, not just once) diff --git a/qbt-relink.sh b/qbt-relink.sh new file mode 100755 index 0000000..ffa4668 --- /dev/null +++ b/qbt-relink.sh @@ -0,0 +1,121 @@ +#!/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"