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
This commit is contained in:
@@ -37,7 +37,53 @@ once.
|
||||
5. **Clean up the empty source folder**:
|
||||
`docker exec jellyfin rmdir "<raw folder path>"` (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 <torrent-hash> <new-season-folder>` (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 "<show name>"
|
||||
```
|
||||
|
||||
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 <hash>
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Executable
+121
@@ -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 <torrent-hash> <new-folder>
|
||||
#
|
||||
# <new-folder> 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 <new-folder> 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 <torrent-hash> <new-folder>" >&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"
|
||||
Reference in New Issue
Block a user