#!/bin/sh # synomap_apply_from_plan_v1.6.1_sequential.sh # Objectif : appliquer un plan HASH|SRC|DST|CAT en **séquentiel strict** # - Sidecars: --sidecars=copy|ignore|redownload (défaut copy) # - Bind-mount "no-copy" si save_path=/data/... et DST sous /syno (évite la copie réseau) # - Pause → AutoTMM OFF → hardlink → (sidecars) → setLocation → wait moving → recheck → resume → tag # Dépendances: curl jq awk sed mount umount ln cp # POSIX /bin/sh # # Usage: # sh synomap_apply_from_plan_v1.6.1_sequential.sh [--write] [--sleep-seconds N] \ # [--move-timeout-sec S] [--recheck-timeout-sec S] [--sidecars copy|ignore|redownload] \ # [--no-bind] # # v1.6.1 (2025-11-13): fix parsing (case/esac), robust filePrio, minor logs. set -eu # ---------- helpers ---------- ts() { date '+%Y-%m-%d %H:%M:%S'; } die() { echo "[ERR] $*" >&2; exit 1; } SCRIPT="$0" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT")" && pwd) # Defaults WRITE=0 SLEEP_SECS=2 MOVE_TIMEOUT=900 RECHECK_TIMEOUT=900 SIDECARS_MODE="copy" # copy|ignore|redownload USE_BIND=1 PLAN="" # ---------- parse args ---------- while [ $# -gt 0 ]; do case "$1" in --write) WRITE=1; shift ;; --sleep-seconds) SLEEP_SECS="${2:?}"; shift 2 ;; --move-timeout-sec) MOVE_TIMEOUT="${2:?}"; shift 2 ;; --recheck-timeout-sec) RECHECK_TIMEOUT="${2:?}"; shift 2 ;; --sidecars) SIDECARS_MODE="${2:?}"; shift 2 ;; --no-bind) USE_BIND=0; shift ;; --help|-h) echo "see header"; exit 0 ;; *) PLAN="$1"; shift ;; esac done [ -n "${PLAN:-}" ] || die "plan file required" [ -f "$PLAN" ] || die "plan not found: $PLAN" # ---------- env ---------- # Tente synomap.env si QB_URL absent if [ -z "${QB_URL:-}" ] && [ -f "$SCRIPT_DIR/synomap.env" ]; then # shellcheck disable=SC1091 . "$SCRIPT_DIR/synomap.env" fi : "${QB_URL:?env QB_URL missing}" : "${QB_USER:?env QB_USER missing}" : "${QB_PASS:?env QB_PASS missing}" COOKIE="/tmp/qb_cookie_$$" trap 'rm -f "$COOKIE"' EXIT INT TERM # Tools check for b in curl jq awk sed ln mount umount cp; do command -v "$b" >/dev/null 2>&1 || die "missing binary: $b" done # ---------- qBittorrent API ---------- qb_login() { curl -s -c "$COOKIE" --data-urlencode "username=$QB_USER" --data-urlencode "password=$QB_PASS" \ "$QB_URL/api/v2/auth/login" >/dev/null || die "qB login failed" } qb_post() { ep="$1"; shift curl -s -b "$COOKIE" "$QB_URL/api/v2/$ep" "$@" } qb_info_json() { qb_post "torrents/info" --data-urlencode "hashes=$1"; } qb_files_json() { qb_post "torrents/files" --data-urlencode "hash=$1"; } qb_pause() { qb_post "torrents/pause" --data-urlencode "hashes=$1" >/dev/null; } qb_resume() { qb_post "torrents/resume" --data-urlencode "hashes=$1" >/dev/null; } qb_auto_tmm_off() { qb_post "torrents/setAutoTMM" --data-urlencode "hashes=$1" --data-urlencode "enable=false" >/dev/null || true; } qb_set_location() { qb_post "torrents/setLocation" --data-urlencode "hashes=$1" --data-urlencode "location=$2" >/dev/null; } qb_recheck() { qb_post "torrents/recheck" --data-urlencode "hashes=$1" >/dev/null; } qb_add_tags() { qb_post "torrents/addTags" --data-urlencode "hashes=$1" --data-urlencode "tags=$2" >/dev/null; } # ---------- helpers: json ---------- get_info_field() { echo "$1" | jq -r '.[0]'"$2 // empty"; } wait_until_not_moving() { h="$1"; timeout="$2"; waited=0 while :; do info="$(qb_info_json "$h")" state="$(get_info_field "$info" '.state')" savep="$(get_info_field "$info" '.save_path')" printf "[%s] move: state=%s save_path=%s\n" "$(ts)" "$state" "$savep" echo "$state" | grep -qi "moving" || break sleep 2; waited=$((waited+2)) [ $waited -ge "$timeout" ] && { echo "[WARN] move timeout"; return 1; } done return 0 } wait_recheck_done() { h="$1"; timeout="$2"; waited=0 while :; do info="$(qb_info_json "$h")" state="$(get_info_field "$info" '.state')" prog="$(get_info_field "$info" '.progress')" printf "[%s] recheck: state=%s progress=%s\n" "$(ts)" "$state" "$prog" [ "$prog" = "1" ] && break echo "$state" | grep -qi "check" || break sleep 2; waited=$((waited+2)) [ $waited -ge "$timeout" ] && { echo "[WARN] recheck timeout"; return 1; } done return 0 } list_sidecars_indexes_and_names() { qb_files_json "$1" | jq -r ' to_entries[] | select(.value.name | test("\\.(nfo|jpg|jpeg|png|sfv|txt|srt|ass|ssa)$"; "i")) | "\(.key)|\(.value.name)" ' } get_dirs() { h="$1"; dst="$2" info="$(qb_info_json "$h")" content_path="$(get_info_field "$info" '.content_path')" if [ -z "$content_path" ]; then sp="$(get_info_field "$info" '.save_path')" first="$(qb_files_json "$h" | jq -r '.[0].name')" if [ -n "$first" ]; then case "$first" in */*) root="$sp/$(echo "$first" | cut -d/ -f1)";; *) root="$sp";; esac content_path="$root" else content_path="$sp" fi fi src_dir="$(dirname "$content_path")" dst_dir="$(dirname "$dst")" echo "$src_dir|$dst_dir" } copy_sidecars() { h="$1"; src_dir="$2"; dst_dir="$3" list="$(list_sidecars_indexes_and_names "$h" | cut -d'|' -f2-)" [ -z "$list" ] && { echo "[sidecars] none to copy"; return 0; } echo "$list" | while IFS= read -r rel; do base=$(basename -- "$rel") s="$src_dir/$base" d="$dst_dir/$base" if [ -f "$s" ]; then cp -a "$s" "$d" && echo "[sidecars] copied: $base" else echo "[sidecars] missing local: $s" fi done } set_sidecars_prio() { h="$1"; prio="$2" idxs="$(list_sidecars_indexes_and_names "$h" | cut -d'|' -f1 | paste -sd '|' -)" [ -n "$idxs" ] || { echo "[sidecars] no indexes"; return 0; } qb_post "torrents/filePrio" \ --data-urlencode "hashes=$h" \ --data-urlencode "id=$idxs" \ --data-urlencode "priority=$prio" >/dev/null || true echo "[sidecars] priority=$prio on [$idxs]" } echo "== APPLY (sequential strict v1.6.1) ==" echo "write=$WRITE sleep=${SLEEP_SECS}s move_timeout=${MOVE_TIMEOUT}s recheck_timeout=${RECHECK_TIMEOUT}s sidecars=${SIDECARS_MODE} bind=${USE_BIND}" echo "plan=$PLAN" qb_login # Lire le plan par blocs (ligne HASH + ligne plan: ln 'SRC' -> 'DST') awk '/^[0-9a-f]{40}[[:space:]]/ {print; getline; print; }' "$PLAN" | \ while IFS= read -r header; do IFS= read -r planline || true HASH=$(echo "$header" | awk '{print $1}') SRC=$(echo "$planline" | sed -n "s/.*plan: ln '\(.*\)' -> '\(.*\)'.*/\1/p") DST=$(echo "$planline" | sed -n "s/.*plan: ln '\(.*\)' -> '\(.*\)'.*/\2/p") [ -n "$HASH" ] && [ -n "$SRC" ] && [ -n "$DST" ] || { echo "[WARN] skip malformed block"; continue; } CAT="$(echo "$header" | sed -n 's/.*cat=\([a-zA-Z0-9_-]*\).*/\1/p')" [ -n "$CAT" ] || { case "$DST" in */radarr/*) CAT="radarr";; */sonarr/*) CAT="sonarr";; *) CAT="unknown";; esac; } DEST_DIR=$(dirname -- "$DST") echo "--- [$HASH] ---" echo "SRC=$SRC" echo "DST=$DST" echo "DEST_DIR=$DEST_DIR" qb_pause "$HASH" qb_auto_tmm_off "$HASH" if [ -f "$DST" ]; then echo "link: exists" else ln "$SRC" "$DST" 2>/dev/null && echo "link: created" || echo "link: create failed (continue)" fi DIRS="$(get_dirs "$HASH" "$DST")" SRC_DIR=$(echo "$DIRS" | cut -d'|' -f1) DST_DIR=$(echo "$DIRS" | cut -d'|' -f2) case "$SIDECARS_MODE" in copy) copy_sidecars "$HASH" "$SRC_DIR" "$DST_DIR"; set_sidecars_prio "$HASH" 1 ;; ignore) set_sidecars_prio "$HASH" 0 ;; redownload)set_sidecars_prio "$HASH" 1 ;; esac if [ "$WRITE" -eq 1 ]; then USED_BIND=0 info="$(qb_info_json "$HASH")" savep="$(get_info_field "$info" '.save_path')" case "$savep" in /data/*) if [ $USE_BIND -eq 1 ]; then TOR_DIRNAME="$(basename -- "$DST_DIR")" SRC_DATA_DIR="$savep/$TOR_DIRNAME" [ -d "$SRC_DATA_DIR" ] || mkdir -p "$SRC_DATA_DIR" if mountpoint -q "$SRC_DATA_DIR"; then echo "bind: already $SRC_DATA_DIR" else if mount --bind "$DST_DIR" "$SRC_DATA_DIR"; then echo "bind: $SRC_DATA_DIR -> $DST_DIR" USED_BIND=1 else echo "[WARN] bind failed (continue without)" fi fi fi ;; esac qb_set_location "$HASH" "$DEST_DIR" wait_until_not_moving "$HASH" "$MOVE_TIMEOUT" || echo "[WARN] moving not confirmed" qb_recheck "$HASH" wait_recheck_done "$HASH" "$RECHECK_TIMEOUT" || echo "[WARN] recheck not confirmed" qb_resume "$HASH" qb_add_tags "$HASH" "SYNO" echo "done: $HASH" if [ $USED_BIND -eq 1 ]; then umount "$SRC_DATA_DIR" || true echo "bind: umount $SRC_DATA_DIR" fi else echo "[PREVIEW] would setLocation -> $DEST_DIR ; then recheck/resume/tag" fi sleep "$SLEEP_SECS" done echo "Done (apply sequential strict v1.6.1)."