#!/bin/sh # POSIX-only, /bin/sh compatible, ASCII-only # Dry-run by default; use --write to apply changes # Logs: $LOG_DIR (from env), Plan: $PLAN_OUT set -u # Load env if [ -f "/etc/synomap/synomap.env" ]; then . /etc/synomap/synomap.env fi # Defaults if not set QB_URL="${QB_URL:-}" QB_USER="${QB_USER:-}" QB_PASS="${QB_PASS:-}" MAP_FILE="${MAP_FILE:-}" DEST_ROOT="${DEST_ROOT:-}" QB_CATEGORIES="${QB_CATEGORIES:-sonarr,radarr}" QB_EXCLUDE_CATEGORIES="${QB_EXCLUDE_CATEGORIES:-hum}" TAG_DONE="${TAG_DONE:-SYNO}" PLAN_OUT="${PLAN_OUT:-/root/synomap_plan.txt}" LOG_DIR="${LOG_DIR:-/var/log/synomap}" LOCK_DIR="${LOCK_DIR:-/var/lock/synomap}" UMASK_VAL="${UMASK:-002}" umask "$UMASK_VAL" mkdir -p "$LOG_DIR" "$LOCK_DIR" 2>/dev/null || true DRY=1 WRITE=0 PLAN="" LIMIT="" log() { printf "%s %s " "$(date +%Y-%m-%dT%H:%M:%S)" "$*" | tee -a "$LOG_DIR/synomap.log" ; } nok() { log "[NOK] $*"; } ok() { log "[OK] $*"; } note(){ log "[NOTE] $*"; } qb_cookie="" qb_login() { qb_cookie="$(mktemp)" curl -sS -c "$qb_cookie" --data-urlencode "username=$QB_USER" --data-urlencode "password=$QB_PASS" "$QB_URL/api/v2/auth/login" >/dev/null 2>&1 || return 1 return 0 } qb_api() { m="$1"; shift p="$1"; shift if [ "$m" = "GET" ]; then curl -sS -b "$qb_cookie" "$QB_URL$p" 2>/dev/null else curl -sS -b "$qb_cookie" -X POST "$QB_URL$p" "$@" 2>/dev/null fi } qb_info_many() { qb_api GET "/api/v2/torrents/info" } qb_set_location() { qb_api POST "/api/v2/torrents/setLocation" --data "hashes=$1" --data-urlencode "location=$2" } qb_add_tags() { qb_api POST "/api/v2/torrents/addTags" --data "hashes=$1" --data-urlencode "tags=$2" } qb_recheck() { qb_api POST "/api/v2/torrents/recheck" --data "hashes=$1" } poll_state() { h="$1" tm="${2:-120}" i=0 while [ "$i" -lt "$tm" ]; do st="$(qb_api GET "/api/v2/torrents/info?hashes=$h" | tr -d '\r' | tr -d '\n' )" state="$(printf "%s" "$st" | sed -n 's/.*\"state\":\"\([^\"]*\)\".*/\1/p')" if [ -n "$state" ] ; then log "[STATE] $h = $state" case "$state" in checking*|queuedForChecking) : ;; downloading|stalledDL|stalledUP|uploading|pausedUP|pausedDL) break ;; *) : ;; esac fi sleep 2 i=$((i+2)) done } # CLI parse while [ $# -gt 0 ]; do case "$1" in --write) DRY=0; WRITE=1; shift;; --dry-run) DRY=1; WRITE=0; shift;; --plan) PLAN="$2"; shift 2;; --limit) LIMIT="$2"; shift 2;; *) note "arg ignored: $1"; shift;; esac done # synomap_apply_from_plan.sh # Execute migration from a plan: hash|src|dst|category if [ -n "$PLAN" ]; then PLAN_IN="$PLAN" else PLAN_IN="$PLAN_OUT" fi [ -f "$PLAN_IN" ] || { nok "Plan missing: $PLAN_IN"; exit 0; } ok "Login to qBittorrent" if ! qb_login; then nok "qB login failed" exit 0 fi i=0 while IFS='|' read -r h src dst catg; do [ -z "$h" ] && continue i=$((i+1)) log "[STEP] $i hash=$h cat=$catg" if [ "$DRY" -eq 1 ]; then log "MKDIR -p $dst" log "HARDLINK from $src -> $dst (all files)" log "qB:setLocation $h -> $dst ; addTags $TAG_DONE ; recheck" continue fi mkdir -p "$dst" || { nok "mkdir failed: $dst"; continue; } if [ -d "$src" ]; then # link all files; fallback copy if ln fails import_failed=0 # Use find -print0; read -d '' loop import_failed=0 find "$src" -type f -print0 | while IFS= read -r -d '' f; do rel="${f#$src/}" out="$dst/$rel" odir="$(dirname "$out")" mkdir -p "$odir" || true if ln "$f" "$out" 2>/dev/null; then : else cp -p "$f" "$out" 2>/dev/null || import_failed=1 fi done if [ "$import_failed" -ne 0 ]; then nok "file import encountered errors for $src" fi else nok "src not a dir: $src" fi qb_api POST "/api/v2/torrents/setLocation" --data "hashes=$h" --data-urlencode "location=$dst" >/dev/null qb_api POST "/api/v2/torrents/addTags" --data "hashes=$h" --data-urlencode "tags=$TAG_DONE" >/dev/null qb_api POST "/api/v2/torrents/recheck" --data "hashes=$h" >/dev/null # poll st="$(poll_state "$h" 120)" done < "$PLAN_IN" ok "Apply finished" rm -f "$qb_cookie" exit 0