#!/bin/sh # synomap_apply_from_plan_v1.6.2_sequential.sh # Séquentiel strict + sidecars + bind "no-copy" # - Parsing plan **robuste**: associe chaque 'plan: ln ... -> ...' au **dernier hash** vu (même si intercalé). # - --debug pour afficher le nombre d'items détectés et les 3 premiers. # v1.6.2 (2025-11-13) set -eu ts() { date '+%Y-%m-%d %H:%M:%S'; } die() { echo "[ERR] $*" >&2; exit 1; } SCRIPT="$0" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT")" && pwd) WRITE=0 SLEEP_SECS=2 MOVE_TIMEOUT=900 RECHECK_TIMEOUT=900 SIDECARS_MODE="copy" # copy|ignore|redownload USE_BIND=1 DEBUG=0 PLAN="" 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 ;; --debug) DEBUG=1; 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" # Charger env si dispo 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 for b in curl jq awk sed ln mount umount cp paste; do command -v "$b" >/dev/null 2>&1 || die "missing binary: $b" done 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; } 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.2) ==" 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 TMPLIST="/tmp/plan_items_$$.lst" trap 'rm -f "$TMPLIST" "$COOKIE"' EXIT INT TERM # Extraction robuste des items du plan en lignes: HASH|SRC|DST awk ' /^[0-9A-Fa-f]{40}[[:space:]]/ { last_hash=$1; next } /plan:[[:space:]]+ln[[:space:]]+.\047.*\047[[:space:]]+->[[:space:]]+\047.*\047/ { line=$0 # Extraire SRC et DST entre quotes src=line; gsub(/.*plan:[[:space:]]+ln[[:space:]]+\047/,"",src) dst=line; sub(/.*->[[:space:]]+\047/,"",dst) sub(/\047.*/,"",src) sub(/\047.*/,"",dst) if (last_hash != "") { printf("%s|%s|%s\n", last_hash, src, dst) } } ' "$PLAN" > "$TMPLIST" ITEMS=$(wc -l < "$TMPLIST" | awk '{print $1}') [ "$DEBUG" -eq 1 ] && { echo "[debug] items=$ITEMS"; head -n 3 "$TMPLIST"; } if [ "$ITEMS" -eq 0 ]; then echo "[INFO] 0 item exploitable dans le plan (rien à faire)." exit 0 fi # Boucle séquentielle while IFS='|' read -r HASH SRC DST; do 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 < "$TMPLIST" echo "Done (apply sequential strict v1.6.2)."