#!/bin/sh # POSIX-only, /bin/sh compatible, ASCII-only # Dry-run by default; use --write to apply changes # Logs: $LOG_DIR, Plan: $PLAN_OUT set -u # Load env if [ -f "/etc/synomap/synomap.env" ]; then . /etc/synomap/synomap.env fi QB_URL="${QB_URL:-}"; QB_USER="${QB_USER:-}"; QB_PASS="${QB_PASS:-}" MAP_FILE="${MAP_FILE:-}"; TORR_ROOT="${TORR_ROOT:-/syno/torrents/completed}" 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}"; DIRECTION="${DIRECTION:-LIB_TO_TORRENTS}" QB_TIMEOUT="${QB_TIMEOUT:-10}" QB_FETCH_LIMIT="${QB_FETCH_LIMIT:-500}" QB_FETCH_FACTOR="${QB_FETCH_FACTOR:-20}" umask "$UMASK_VAL"; mkdir -p "$LOG_DIR" "$LOCK_DIR" 2>/dev/null || true DRY=1; WRITE=0; PLAN=""; LIMIT="" log(){ printf "%s %s\n" "$(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 -m "$QB_TIMEOUT" -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 -m "$QB_TIMEOUT" -sS -b "$qb_cookie" "$QB_URL$p" 2>/dev/null else curl -m "$QB_TIMEOUT" -sS -b "$qb_cookie" -X POST "$QB_URL$p" "$@" 2>/dev/null fi } qb_info_many(){ lim="$QB_FETCH_LIMIT" if [ -n "$LIMIT" ]; then case "$LIMIT" in *[!0-9]*|"") : ;; *) lim=$((LIMIT * QB_FETCH_FACTOR));; esac fi qb_api GET "/api/v2/torrents/info?limit=$lim" } 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\n')" state="$(printf "%s" "$st" | sed -n 's/.*\"state\":\"\([^\"]*\)\".*/\1/p')" [ -n "$state" ] && log "[STATE] $h = $state" case "$state" in checking*|queuedForChecking) : ;; *) break ;; esac sleep 2; i=$((i+2)) done } 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;; case_esac=true esac done # synomap_miniplan_pick.sh (r8) # Output: hash|src_lib_dir|dst_torr_dir|category [ -n "$PLAN" ] && PLAN_OUT="$PLAN" [ "$DIRECTION" = "LIB_TO_TORRENTS" ] || { nok "Unsupported DIRECTION=$DIRECTION"; exit 0; } # 1) Parse mapping: collect per-category LIB_ROOT / TORR_ROOT and library filename index tmp_map="$(mktemp)"; : > "$tmp_map" # Lines with 3 fields define roots; with 2 fields define concrete library files if [ -f "$MAP_FILE" ]; then ok "Loading mapping: $MAP_FILE" while IFS= read -r line; do case "$line" in ""|"#"* ) continue;; esac nf=$(printf "%s" "$line" | awk -F'|' '{print NF}') cat="$(printf "%s" "$line" | cut -d'|' -f1 | tr '[:upper:]' '[:lower:]')" if [ "$nf" -ge 3 ]; then lib="$(printf "%s" "$line" | cut -d'|' -f2)" tor="$(printf "%s" "$line" | cut -d'|' -f3-)" eval "LIB_$cat=\"$lib\"" eval "TORR_$cat=\"$tor\"" elif [ "$nf" -ge 2 ]; then fp="$(printf "%s" "$line" | cut -d'|' -f2-)" bn="$(basename "$fp")" printf "%s|%s|%s\n" "$cat" "$bn" "$fp" >> "$tmp_map" fi done < "$MAP_FILE" else nok "MAP_FILE missing: $MAP_FILE"; exit 0 fi # Default roots if not set by 3-field mapping for c in $(printf "%s" "$QB_CATEGORIES" | tr '[:upper:]' '[:lower:]' | tr ',' ' '); do eval "libv=\${LIB_$c:-}"; eval "torv=\${TORR_$c:-}" if [ -z "$libv" ]; then eval "LIB_$c=\${LIB_ROOT_${c}:-/syno/$c}" # fallback per-env variable fi if [ -z "$torv" ]; then eval "TORR_$c=\"$TORR_ROOT/$c\"" fi done # 2) qB login + fetch info ok "Login to qBittorrent" if ! qb_login; then nok "qB login failed"; exit 0; fi J="$(qb_info_many)"; [ -z "$J" ] && { nok "qB info empty"; exit 0; } incl="$(printf "%s" "$QB_CATEGORIES" | tr '[:upper:]' '[:lower:]' | tr ',' ' ')" excl="$(printf "%s" "$QB_EXCLUDE_CATEGORIES" | tr '[:upper:]' '[:lower:]' | tr ',' ' ')" tmp_plan="$(mktemp)"; : > "$tmp_plan" # function: find a library dir for a torrent by matching filenames find_lib_dir(){ cat="$1"; files_json="$2" if command -v jq >/dev/null 2>&1; then printf "%s" "$files_json" | jq -r '.[].name' | while IFS= read -r name; do bn="$(basename "$name")" # lookup cat|bn in tmp_map match="$(grep -F "|$bn" "$tmp_map" | awk -F'|' -v c="$cat" '$1==c {print $3; exit}')" if [ -n "$match" ]; then dirname "$match" return 0 fi done else # crude fallback: first file name bn="$(printf "%s" "$files_json" | tr -d '\n\r' | sed 's/},{/}\n{/g' | sed -n 's/.*"name":"\([^"]*\)".*/\1/p' | head -n1 | xargs basename)" match="$(grep -F "|$bn" "$tmp_map" | head -n1 | cut -d'|' -f3-)" [ -n "$match" ] && dirname "$match" fi } emit_line(){ h="$1"; sp="$2"; catg="$3" cl="$(printf "%s" "$catg" | tr '[:upper:]' '[:lower:]')" eval "libr=\${LIB_$cl}"; eval "torr=\${TORR_$cl}" files="$(qb_api GET "/api/v2/torrents/files?hash=$h")" libdir="$(find_lib_dir "$cl" "$files")" [ -z "$libdir" ] && { note "no library match for $h ($cl)"; return; } # Relativize library dir to libr case "$libdir" in "$libr"*) rel="${libdir#$libr/}" ;; *) # if libdir not under libr, try to align by series folder (2 levels) rel="$(basename "$(dirname "$libdir")")/$(basename "$libdir")" ;; esac dst="$torr/$rel" printf "%s|%s|%s|%s\n" "$h" "$libdir" "$dst" "$cl" >> "$tmp_plan" } # Iterate torrents if command -v jq >/dev/null 2>&1; then printf "%s" "$J" | jq -cr '.[] | {h:.hash, sp:.save_path, c:(.category//""), tags:(.tags//"")} | @base64' \ | while read -r row; do obj="$(printf "%s" "$row" | base64 -d)" h="$(printf "%s" "$obj" | jq -r '.h')" sp="$(printf "%s" "$obj" | jq -r '.sp')" c0="$(printf "%s" "$obj" | jq -r '.c')" t="$(printf "%s" "$obj" | jq -r '.tags')" [ -z "$h" ] || [ -z "$sp" ] && continue cl="$(printf "%s" "$c0" | tr '[:upper:]' '[:lower:]')" case ",$t," in *",$TAG_DONE,"*) continue;; esac skip=0; for x in $excl; do [ "$cl" = "$x" ] && skip=1 && break; done; [ "$skip" -eq 1 ] && continue want=0; for c in $incl; do [ "$cl" = "$c" ] && want=1 && break; done; [ "$want" -eq 0 ] && continue emit_line "$h" "$sp" "$cl" done else printf "%s" "$J" | tr -d '\n\r' | sed 's/},{/}\n{/g' | while IFS= read -r obj; do h="$(printf "%s" "$obj" | sed -n 's/.*\"hash\":\"\([^\"]*\)\".*/\1/p' | head -n1)" sp="$(printf "%s" "$obj" | sed -n 's/.*\"save_path\":\"\([^\"]*\)\".*/\1/p' | head -n1)" c0="$(printf "%s" "$obj" | sed -n 's/.*\"category\":\"\([^\"]*\)\".*/\1/p' | head -n1)" t="$(printf "%s" "$obj" | sed -n 's/.*\"tags\":\"\([^\"]*\)\".*/\1/p' | head -n1)" [ -z "$h" ] && continue [ -z "$sp" ] && continue cl="$(printf "%s" "$c0" | tr '[:upper:]' '[:lower:]')" case ",$t," in *",$TAG_DONE,"*) continue;; esac skip=0; for x in $excl; do [ "$cl" = "$x" ] && skip=1 && break; done; [ "$skip" -eq 1 ] && continue want=0; for c in $incl; do [ "$cl" = "$c" ] && want=1 && break; done; [ "$want" -eq 0 ] && continue emit_line "$h" "$sp" "$cl" done fi count="$(wc -l < "$tmp_plan" | tr -d ' ')" [ -n "$LIMIT" ] && [ "$LIMIT" -gt 0 ] && head -n "$LIMIT" "$tmp_plan" > "$tmp_plan.head" && mv "$tmp_plan.head" "$tmp_plan" [ "$DRY" -eq 1 ] && ok "Plan preview ($count items):" && sed -n '1,50p' "$tmp_plan" mv "$tmp_plan" "$PLAN_OUT"; ok "Plan written to $PLAN_OUT" rm -f "$qb_cookie" "$tmp_map" exit 0