assemblrr/scripts/media-purge-watch.sh
soulis-1256 edac50f8a2
Fix specials wipe, leftover torrent dirs, and default-stack pins
Keep TV specials when a request covers only regular seasons. Encode
first-run passwords, default missing config-show fields, and strip
Windows CRLF on Python, Dockerfiles, and migrations. Pin express-stack
images. After Sonarr title delete, remove leftover torrents/ dirs even
when qB already dropped the torrent. Document that only the default
stack is supported.
2026-08-16 21:19:15 +03:00

325 lines
11 KiB
Bash
Executable file

#!/bin/bash
# inotify on /data/media → media-purge when a library *video* is deleted.
# Ignores *arr write-tests, sidecars, and upgrade races (new file not yet written).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PURGE="${SCRIPT_DIR}/media-purge.sh"
MEDIA_ROOT="${MEDIA_ROOT:-/data}"
WATCH_ROOT="${WATCH_ROOT:-${MEDIA_ROOT}/media}"
# First look: skip if a replacement video is already there (normal import).
DEBOUNCE_SEC="${DEBOUNCE_SEC:-8}"
# Second look: Radarr/Sonarr upgrades delete the old file before writing the new one.
UPGRADE_GRACE_SEC="${UPGRADE_GRACE_SEC:-20}"
export API_HOST="${API_HOST:-127.0.0.1}"
export RADARR_URL="${RADARR_URL:-http://radarr:7878}"
export SONARR_URL="${SONARR_URL:-http://sonarr:8989}"
export JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}"
export JELLYFIN_API_KEY_FILE="${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}"
export CONFIG_DIR="${CONFIG_DIR:-/config}"
export AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
export AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"
log() { echo "media-purge-watch: $*" >&2; }
# Setup write-tests, editor junk, and incomplete tmp names must never purge.
purge_watch_is_ignored() {
local p="${1:-}"
local base="${p##*/}"
case "$base" in
""|.|..|.DS_Store|Thumbs.db) return 0 ;;
*write_test*|*_write_test.txt|radarr_write_test.txt|sonarr_write_test.txt) return 0 ;;
.nfs*|.tmp*|*.tmp|*.partial|*.!qB|*.part) return 0 ;;
esac
case "$p" in
*/.nfs*|*/.tmp*|*/tmp/*|*/.git/*) return 0 ;;
*/media/.recycle|*/media/.recycle/*|*/.recycle|*/.recycle/*) return 0 ;;
esac
return 1
}
# Title-folder recycle/delete (not an nfo/jpg inside the tree).
purge_watch_is_title_folder() {
local p="${1%/}"
local folder
folder=$(purge_watch_library_folder "$p")
[ -n "$folder" ] && [ "$p" = "$folder" ]
}
purge_watch_is_video() {
local p="${1:-}"
local ext="${p##*.}"
ext=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext" in
mkv|mp4|avi|m4v|ts|m2ts|webm|mov|wmv|mpg|mpeg) return 0 ;;
*) return 1 ;;
esac
}
# Folder we would ask *arr to delete (title directory under movies/ or tv/).
purge_watch_library_folder() {
local p="$1"
case "$p" in
*/media/movies/*) echo "$p" | sed -E 's|(.*/media/movies/[^/]+).*|\1|' ;;
*/media/tv/*) echo "$p" | sed -E 's|(.*/media/tv/[^/]+).*|\1|' ;;
*) echo "" ;;
esac
}
# Season number from a library path (.../Season 2/ep.mkv or .../Specials/...). Empty if none.
purge_watch_season_number() {
local p="${1:-}"
local n
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)/.*|\1|p')
if [ -z "$n" ]; then
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)$|\1|p')
fi
if [ -z "$n" ]; then
case "$p" in
*/[Ss]pecials|*/[Ss]pecials/*) echo 0; return 0 ;;
esac
return 0
fi
echo $((10#$n))
}
purge_watch_season_folder() {
local series_folder="$1"
local season="$2"
local cand
[ -n "$series_folder" ] || return 1
if [ "$season" = "0" ]; then
for cand in "${series_folder}/Specials" "${series_folder}/specials"; do
[ -d "$cand" ] && echo "$cand" && return 0
done
return 1
fi
for cand in \
"${series_folder}/Season ${season}" \
"${series_folder}/Season $(printf '%02d' "$season")" \
"${series_folder}/season ${season}" \
"${series_folder}/season $(printf '%02d' "$season")"
do
[ -d "$cand" ] && echo "$cand" && return 0
done
return 1
}
purge_watch_folder_has_video() {
local folder="$1"
[ -d "$folder" ] || return 1
find "$folder" -type f \( \
-iname '*.mkv' -o -iname '*.mp4' -o -iname '*.avi' -o \
-iname '*.m4v' -o -iname '*.ts' -o -iname '*.m2ts' -o \
-iname '*.webm' -o -iname '*.mov' \
\) 2>/dev/null | grep -q .
}
# Incomplete copies / qB leftovers mean an upgrade is still writing.
purge_watch_folder_busy() {
local folder="$1"
[ -d "$folder" ] || return 1
purge_watch_folder_has_video "$folder" && return 0
find "$folder" -type f \( \
-iname '*.part' -o -iname '*.!qB' -o -iname '*.partial' -o \
-iname '*.tmp' -o -iname '*.temp' \
\) 2>/dev/null | grep -q .
}
purge_watch_arr_key() {
local svc="$1"
local f="${CONFIG_DIR:-/config}/${svc}/config.xml"
[ -f "$f" ] || return 1
sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' "$f" 2>/dev/null | head -1
}
# History JSON: hold if a recent file-delete was an *arr upgrade.
purge_watch_json_has_upgrade_delete() {
printf '%s' "${1:-}" | jq -e '
def recs: if type == "array" then . elif has("records") then .records else [] end;
recs[]
| ((.eventType // "") | ascii_downcase) as $e
| ((.data.reason // "") | ascii_downcase) as $r
| select(($e == "moviefiledeleted" or $e == "episodefiledeleted") and $r == "upgrade")
' >/dev/null 2>&1
}
# Queue JSON: hold if movieId/seriesId is in the queue.
purge_watch_queue_mentions_id() {
local json="${1:-}"
local key="$2"
local id="$3"
[ -n "$id" ] || return 1
printf '%s' "$json" | jq -e --arg k "$key" --argjson id "$id" '
def recs: if type == "array" then . elif has("records") then .records else [] end;
recs | any(.[]; ((.[$k] // 0) | tonumber) == $id)
' >/dev/null 2>&1
}
purge_watch_arr_get() {
local url="$1" key="$2" path="$3"
curl -sf --connect-timeout 3 -H "X-Api-Key: ${key}" "${url}${path}" 2>/dev/null || true
}
# 0 = do not purge (upgrade/import still in flight, or *arr unreachable).
purge_watch_arr_hold() {
local arr_path="$1"
local season="${2:-}"
local key json id hist queue
if [ -n "$season" ]; then
key=$(purge_watch_arr_key sonarr || true)
[ -n "$key" ] || return 0
json=$(purge_watch_arr_get "${SONARR_URL:-http://sonarr:8989}" "$key" "/api/v3/series")
id=$(printf '%s' "$json" | jq -r --arg p "$arr_path" '
.[]? | select((.path // "") == $p) | .id
' 2>/dev/null | head -1)
[ -n "$id" ] || return 1
queue=$(purge_watch_arr_get "${SONARR_URL:-http://sonarr:8989}" "$key" "/api/v3/queue")
purge_watch_queue_mentions_id "$queue" "seriesId" "$id" && return 0
hist=$(purge_watch_arr_get "${SONARR_URL:-http://sonarr:8989}" "$key" "/api/v3/history?seriesId=${id}&pageSize=20")
purge_watch_json_has_upgrade_delete "$hist" && return 0
return 1
fi
key=$(purge_watch_arr_key radarr || true)
[ -n "$key" ] || return 0
json=$(purge_watch_arr_get "${RADARR_URL:-http://radarr:7878}" "$key" "/api/v3/movie")
id=$(printf '%s' "$json" | jq -r --arg p "$arr_path" '
.[]? | select((.path // "") == $p) | .id
' 2>/dev/null | head -1)
[ -n "$id" ] || return 1
queue=$(purge_watch_arr_get "${RADARR_URL:-http://radarr:7878}" "$key" "/api/v3/queue")
purge_watch_queue_mentions_id "$queue" "movieId" "$id" && return 0
hist=$(purge_watch_arr_get "${RADARR_URL:-http://radarr:7878}" "$key" "/api/v3/history?movieId=${id}&pageSize=20")
purge_watch_json_has_upgrade_delete "$hist" && return 0
return 1
}
# --- sourced by unit tests: stop here ---
if [ "${PURGE_WATCH_SOURCE_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
[ -x "$PURGE" ] || chmod +x "$PURGE" 2>/dev/null || true
if [ "${MEDIA_PURGE_WATCH:-1}" = "0" ] || [ "${MEDIA_PURGE_WATCH:-1}" = "false" ] || [ "${MEDIA_PURGE_WATCH:-1}" = "off" ]; then
log "disabled (MEDIA_PURGE_WATCH=${MEDIA_PURGE_WATCH}); idling"
exec sleep infinity
fi
if [ -z "${QBITTORRENT_URL:-}" ]; then
if curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "http://gluetun:8081/" 2>/dev/null | grep -qE '^[2345]'; then
export QBITTORRENT_URL="http://gluetun:8081"
elif curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "http://qbittorrent:8081/" 2>/dev/null | grep -qE '^[2345]'; then
export QBITTORRENT_URL="http://qbittorrent:8081"
else
export QBITTORRENT_URL="http://gluetun:8081"
fi
fi
log "watching ${WATCH_ROOT} (qB=${QBITTORRENT_URL})"
LAST_PURGE_PATH=""
LAST_PURGE_TS=0
handle_event() {
local path="$1"
local folder
folder=$(purge_watch_library_folder "$path")
[ -n "$folder" ] || return 0
local now
now=$(date +%s)
local season
season=$(purge_watch_season_number "$path")
# Dedup per season so S01 then S02 in the same window both run.
local dedup_key="$folder"
[ -n "$season" ] && dedup_key="${folder}/s${season}"
if [ "$dedup_key" = "$LAST_PURGE_PATH" ] && [ $((now - LAST_PURGE_TS)) -lt 15 ]; then
return 0
fi
sleep "$DEBOUNCE_SEC"
local arr_path
case "$folder" in
/data/*) arr_path="$folder" ;;
*) arr_path=$(echo "$folder" | sed -E 's|.*/media/|/data/media/|') ;;
esac
if [ -n "$season" ]; then
# Other seasons may only be monitored or still downloading — never
# treat an empty series folder as "the show is done".
local sfolder
sfolder=$(purge_watch_season_folder "$folder" "$season" || true)
if [ -n "$sfolder" ] && purge_watch_folder_busy "$sfolder"; then
return 0
fi
sleep "$UPGRADE_GRACE_SEC"
sfolder=$(purge_watch_season_folder "$folder" "$season" || true)
if [ -n "$sfolder" ] && purge_watch_folder_busy "$sfolder"; then
return 0
fi
if purge_watch_arr_hold "$arr_path" "$season"; then
log "hold $arr_path season ${season} (queue or upgrade in *arr)"
return 0
fi
LAST_PURGE_PATH="$dedup_key"
LAST_PURGE_TS=$(date +%s)
log "purge $arr_path season ${season}"
"$PURGE" --path "$arr_path" --seasons "$season" || log "purge failed for $arr_path season ${season}"
return 0
fi
if purge_watch_folder_busy "$folder"; then
return 0
fi
sleep "$UPGRADE_GRACE_SEC"
if purge_watch_folder_busy "$folder"; then
return 0
fi
if purge_watch_arr_hold "$arr_path" ""; then
log "hold $arr_path (queue or upgrade in *arr)"
return 0
fi
LAST_PURGE_PATH="$dedup_key"
LAST_PURGE_TS=$(date +%s)
log "purge $arr_path"
"$PURGE" --path "$arr_path" || log "purge failed for $arr_path"
}
mkdir -p "$WATCH_ROOT/movies" "$WATCH_ROOT/tv" 2>/dev/null || true
if ! command -v inotifywait >/dev/null 2>&1; then
log "ERROR: inotifywait missing (sidecar image not built?)"
exit 1
fi
# Serial: one event at a time. Backgrounding every inotify line can fork-bomb
# a library delete and race an in-progress *arr upgrade.
inotifywait -m -r \
-e delete,delete_self,moved_from \
--format '%e|%w|%f' \
"$WATCH_ROOT" 2>/dev/null | while IFS='|' read -r events watch_dir filename; do
full="${watch_dir}${filename}"
if purge_watch_is_ignored "$full"; then
continue
fi
if purge_watch_is_video "$full"; then
handle_event "$full"
continue
fi
# Title folder removed or moved to the recycle bin (MOVED_FROM).
# Sonarr "delete files" often renames media/tv/Show → media/.recycle/...
if purge_watch_is_title_folder "$full"; then
case ",${events}," in
*,DELETE_SELF,*|*,MOVED_FROM,*|*,DELETE,*)
handle_event "$full"
;;
esac
fi
done