diff --git a/README.md b/README.md index 0371b75..54eaacb 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ export SHSEEKARR_RADARR_APIKEY="your-radarr-api-key" ./seekarr.sh ``` +Get help: + +```bash +./sh-seekarr.sh -h +``` + Run it on a schedule (cron, systemd timer, build in scheduler, a Sonarr/Radarr *Custom Script* trigger, etc.) to periodically nudge your indexers toward filling gaps and upgrading files, without ever doing a full-library blast search. ### Docker @@ -69,6 +75,12 @@ docker run --name sh-seekarr \ gas85/sh-seekarr:latest ``` +Get help: + +```bash +docker run gas85/sh-seekarr:latest bash /app/sh-seekarr.sh -h +``` + ### Docker-compose Please refer to [docker-compose.yml](https://github.com/GAS85/sh-seekarr/blob/main/docker-compose.yml) example. @@ -77,6 +89,8 @@ Please refer to [docker-compose.yml](https://github.com/GAS85/sh-seekarr/blob/ma All configuration is via environment variables, prefixed `SHSEEKARR_`. +You can get help by calling script with a `-h` or `--help` argument. + ### Connection | Variable | Required | Default | Description | @@ -112,6 +126,7 @@ If an app's URL or API key isn't set, that app is skipped with a log message rat | `SHSEEKARR_READARR_LIMIT` | *(unset)* | If set, overrides `SHSEEKARR_LIMIT` for Readarr only. | | `SHSEEKARR_SONARR_SEASONS_LIMIT` | *(unset)* | If set, overrides `SHSEEKARR_SONARR_SEASONS_LIMIT` for Sonarr only, when requesting whole seasons instead of episodes. | | `SHSEEKARR_PAGE_SIZE` | `200` | Page size used when paging the `wanted/*` endpoints. Larger values mean fewer HTTP round-trips but bigger individual responses. | +| `SHSEEKARR_INDEXER_TEST_ON_FAILURE` | `false` | `true`/`false` (also accepts `1`/`0`, `yes`/`no`). If `true`, force all indexers retest on failure prior to search | **Note on limits:** `SHSEEKARR_LIMIT` and its per-app overrides are applied *independently* per app - e.g. `SHSEEKARR_LIMIT=10` with both Sonarr and Radarr enabled can trigger up to 10 searches on Sonarr **and** up to 10 on Radarr, not 10 combined. diff --git a/sh-seekarr.sh b/sh-seekarr.sh index d36fa9b..6e1b6e9 100755 --- a/sh-seekarr.sh +++ b/sh-seekarr.sh @@ -9,64 +9,77 @@ # just those items. Designed to keep memory low: only numeric ids are ever # held in memory/disk, never full episode/movie/series JSON objects. # +# Execution: +# +# ./sh-seekarr.sh +# +# See help: +# +# ./sh-seekarr.sh [-h|--help] +# # --------------------------------------------------------------------------- # Configuration (environment variables) # --------------------------------------------------------------------------- -# SHSEEKARR_APPS Comma separated list of apps to run. -# Default: "sonarr,radarr" +# SHSEEKARR_APPS Comma separated list of apps to run. +# Default: "sonarr,radarr" +# +# SHSEEKARR_SONARR_URL Base URL of Sonarr, e.g. http://localhost:8989 +# SHSEEKARR_SONARR_APIKEY Sonarr API key # -# SHSEEKARR_SONARR_URL Base URL of Sonarr, e.g. http://localhost:8989 -# SHSEEKARR_SONARR_APIKEY Sonarr API key +# SHSEEKARR_RADARR_URL Base URL of Radarr, e.g. http://localhost:7878 +# SHSEEKARR_RADARR_APIKEY Radarr API key # -# SHSEEKARR_RADARR_URL Base URL of Radarr, e.g. http://localhost:7878 -# SHSEEKARR_RADARR_APIKEY Radarr API key +# SHSEEKARR_LIDARR_URL Base URL of Lidarr, e.g. http://localhost:8686 +# SHSEEKARR_LIDARR_APIKEY Lidarr API key # -# SHSEEKARR_LIDARR_URL Base URL of Lidarr, e.g. http://localhost:8686 -# SHSEEKARR_LIDARR_APIKEY Lidarr API key +# SHSEEKARR_READARR_URL Base URL of Readarr, e.g. http://localhost:8787 +# SHSEEKARR_READARR_APIKEY Readarr API key # -# SHSEEKARR_READARR_URL Base URL of Readarr, e.g. http://localhost:8787 -# SHSEEKARR_READARR_APIKEY Readarr API key +# SHSEEKARR_SEARCH_MODE "missing" | "upgrades" | "both" | "all" +# missing -> only wanted/missing +# upgrades -> only wanted/cutoff (unmet) +# both/all -> union of both, deduplicated +# Default: "missing" # -# SHSEEKARR_SEARCH_MODE "missing" | "upgrades" | "both" | "all" -# missing -> only wanted/missing -# upgrades -> only wanted/cutoff (unmet) -# both/all -> union of both, deduplicated -# Default: "missing" +# SHSEEKARR_MONITORED_ONLY "true" | "false". Default: "true" # -# SHSEEKARR_MONITORED_ONLY "true" | "false". Default: "true" +# SHSEEKARR_LIMIT Max number of items to search PER APP. +# Default: 10 # -# SHSEEKARR_LIMIT Max number of items to search PER APP. -# Default: 10 +# SHSEEKARR_SONARR_LIMIT Set individual of items to search for Sonarr. # -# SHSEEKARR_SONARR_LIMIT Set individual of items to search for Sonarr. +# SHSEEKARR_RADARR_LIMIT Set individual of items to search for Radarr. # -# SHSEEKARR_RADARR_LIMIT Set individual of items to search for Radarr. +# SHSEEKARR_LIDARR_LIMIT Set individual of items to search for Lidarr. # -# SHSEEKARR_LIDARR_LIMIT Set individual of items to search for Lidarr. +# SHSEEKARR_READARR_LIMIT Set individual of items to search for Readarr. # -# SHSEEKARR_READARR_LIMIT Set individual of items to search for Readarr. +# SHSEEKARR_PAGE_SIZE Page size used when paging the Sonarr/Radarr +# wanted endpoints. Default: 200 # -# SHSEEKARR_PAGE_SIZE Page size used when paging the Sonarr/Radarr -# wanted endpoints. Default: 200 +# SHSEEKARR_DRY_RUN "true" | "false". If true, prints what would +# be sent to the command API instead of POSTing +# it. Default: "false" # -# SHSEEKARR_DRY_RUN "true" | "false". If true, prints what would -# be sent to the command API instead of POSTing -# it. Default: "false" +# SHSEEKARR_INDEXER_TEST_ON_FAILURE If true, force all indexers retest on failure +# prior to search. # -# SHSEEKARR_SCHEDULE_INTERVAL Scheduler interval can be integer number for seconds, -# or 's','m','h', or 'd', for seconds, minutes, hours, days. +# SHSEEKARR_SCHEDULE_INTERVAL Scheduler interval can be integer number for +# seconds, or 's','m','h', or 'd', for seconds, +# minutes, hours, days. # -# SHSEEKARR_SCHEDULE_RANDOMIZER Add some random waiting seconds to scheduler interval +# SHSEEKARR_SCHEDULE_RANDOMIZER Add some random waiting seconds to scheduler +# interval within 1 hour. # -# SHSEEKARR_SEARCH_ON_START "true" | "false". If false will search only after -# SHSEEKARR_SCHEDULE_INTERVAL expired. +# SHSEEKARR_SEARCH_ON_START "true" | "false". If false will search only +# after SHSEEKARR_SCHEDULE_INTERVAL expired. # -# SHSEEKARR_LOG_FORMAT "text" or "json". Default: "text" +# SHSEEKARR_LOG_FORMAT "text" or "json". Default: "text" # # An app (sonarr/radarr/lidarr/readarr) is skipped automatically if its URL # or API key is not configured. # -# Requires: curl, jq, sort +# Requires: curl, jq, sort, sed # ---- Config --------------------------------------------------------------- @@ -77,6 +90,8 @@ SHSEEKARR_LIMIT="${SHSEEKARR_LIMIT:-10}" SHSEEKARR_PAGE_SIZE="${SHSEEKARR_PAGE_SIZE:-200}" SHSEEKARR_DRY_RUN="${SHSEEKARR_DRY_RUN:-}" +SHSEEKARR_INDEXER_TEST_ON_FAILURE="${SHSEEKARR_INDEXER_TEST_ON_FAILURE:-false}" + SHSEEKARR_SCHEDULE_INTERVAL="${SHSEEKARR_SCHEDULE_INTERVAL:-}" SHSEEKARR_SCHEDULE_RANDOMIZER="${SHSEEKARR_SCHEDULE_RANDOMIZER:-false}" SHSEEKARR_SEARCH_ON_START="${SHSEEKARR_SEARCH_ON_START:-true}" @@ -102,6 +117,23 @@ SHSEEKARR_READARR_LIMIT="${SHSEEKARR_READARR_LIMIT:-}" # ---- Help Variables --------------------------------------------------------- +show_help() { + sed -n '/^#!/,/^# ---- Config/{ + /^#!/d + /^# ---- Config/d + s/^#// + s/^ // + p + }' "$0" | more +} + +case "${1:-}" in + -h|--help) + show_help + exit 0 + ;; +esac + VERSION="${VERSION:-}" VCS_REF="${VCS_REF:-}" @@ -120,46 +152,58 @@ else fi log() { + local level="$1" + local msg="$2" + local fd=1 + + [[ "$level" == "ERROR" || "$level" == "WARNING" ]] && fd=2 + if [[ ${SHSEEKARR_LOG_FORMAT} == "json" ]]; then - echo "{\"component\":\"${app}\",\"level\":\"$1\",\"msg\":\"$(echo $2 | sed 's/[[:space:]]\+/ /g; s/\\t//g')\",\"time\":\"$(ts)\"}" + jq -cn \ + --arg component "$app" \ + --arg level "$level" \ + --arg msg "$(echo $msg | sed 's/[[:space:]]\+/ /g; s/\\t//g')" \ + --arg time "$(ts)" \ + '{component: $component, level: $level, msg: $msg, time: $time}' >&"$fd" else - echo -e "$(ts) - $1 - ${app} - $(echo "$2" | grep -v '^$')" + echo -e "$(ts) - $1 - ${app} - $(echo "$2" | grep -v '^$')" >&"$fd" fi } # ---- Startup summary -------------------------------------------------------- -log INFO "Welcome to SH Seekarr$([ -n "${VERSION}" ] && echo " version: ${VERSION}")$([ -n "${VCS_REF}" ] && echo " build ${VCS_REF:0:8}"). +log INFO "Welcome to SH Seekarr$([ -n "${VERSION}" ] && echo " version: ${VERSION}")$([ -n "${VCS_REF}" ] && echo -e " build ${VCS_REF:0:8}"). \t\tApps enabled: ${SHSEEKARR_APPS}, \t\tSearch Mode: ${SHSEEKARR_SEARCH_MODE}, \t\tSearch Monitored only: ${SHSEEKARR_MONITORED_ONLY}, \t\tSearch items limit: ${SHSEEKARR_LIMIT}, \t\tPage size: ${SHSEEKARR_PAGE_SIZE}, -$([ -n "${SHSEEKARR_DRY_RUN}" ] && echo "\t\tDry run: ${SHSEEKARR_DRY_RUN},") -$([ -n "${SHSEEKARR_SCHEDULE_INTERVAL}" ] && echo "\t\tScheduler interval: ${SHSEEKARR_SCHEDULE_INTERVAL},") -$([ "${SHSEEKARR_SCHEDULE_RANDOMIZER}" = "true" ] && echo "\t\tScheduler randomizer: enabled,") -$([ "${SHSEEKARR_SEARCH_ON_START}" = "false" ] && echo "\t\tSearch on start: disabled,") +$([ -n "${SHSEEKARR_DRY_RUN}" ] && echo -e "\t\tDry run: ${SHSEEKARR_DRY_RUN},") +$([ -n "${SHSEEKARR_INDEXER_TEST_ON_FAILURE}" ] && echo -e "\t\tForce Indexers retest: ${SHSEEKARR_INDEXER_TEST_ON_FAILURE},") +$([ -n "${SHSEEKARR_SCHEDULE_INTERVAL}" ] && echo -e "\t\tScheduler interval: ${SHSEEKARR_SCHEDULE_INTERVAL},") +$([ "${SHSEEKARR_SCHEDULE_RANDOMIZER}" = "true" ] && echo -e "\t\tScheduler randomizer: enabled,") +$([ "${SHSEEKARR_SEARCH_ON_START}" = "false" ] && echo -e "\t\tSearch on start: disabled,") \t\tLog format: ${SHSEEKARR_LOG_FORMAT}, -$([ -n "${SHSEEKARR_SONARR_URL}" ] && echo "\t\tSonarr URL: ${SHSEEKARR_SONARR_URL},") -$([ -n "${SHSEEKARR_SONARR_APIKEY}" ] && echo "\t\tSonarr API Key: set,") -$([ -n "${SHSEEKARR_SONARR_LIMIT}" ] && echo "\t\tSonarr items limit: ${SHSEEKARR_SONARR_LIMIT},") -$([ -n "${SHSEEKARR_SONARR_SEASONS_LIMIT}" ] && echo "\t\tSonarr seasons limit: ${SHSEEKARR_SONARR_SEASONS_LIMIT},") -$([ -n "${SHSEEKARR_RADARR_URL}" ] && echo "\t\tRadarr URL: ${SHSEEKARR_RADARR_URL},") -$([ -n "${SHSEEKARR_RADARR_APIKEY}" ] && echo "\t\tRadarr API Key: set,") -$([ -n "${SHSEEKARR_RADARR_LIMIT}" ] && echo "\t\tRadarr items limit: ${SHSEEKARR_RADARR_LIMIT},") -$([ -n "${SHSEEKARR_LIDARR_URL}" ] && echo "\t\tLidarr URL: ${SHSEEKARR_LIDARR_URL},") -$([ -n "${SHSEEKARR_LIDARR_APIKEY}" ] && echo "\t\tLidarr API Key: set,") -$([ -n "${SHSEEKARR_LIDARR_LIMIT}" ] && echo "\t\tLidarr items limit: ${SHSEEKARR_LIDARR_LIMIT},") -$([ -n "${SHSEEKARR_READARR_URL}" ] && echo "\t\tReadarr URL: ${SHSEEKARR_READARR_URL},") -$([ -n "${SHSEEKARR_READARR_APIKEY}" ] && echo "\t\tReadarr API Key: set,") -$([ -n "${SHSEEKARR_READARR_LIMIT}" ] && echo "\t\tReadarr items limit: ${SHSEEKARR_READARR_LIMIT}") +$([ -n "${SHSEEKARR_SONARR_URL}" ] && echo -e "\t\tSonarr URL: ${SHSEEKARR_SONARR_URL},") +$([ -n "${SHSEEKARR_SONARR_APIKEY}" ] && echo -e "\t\tSonarr API Key: set,") +$([ -n "${SHSEEKARR_SONARR_LIMIT}" ] && echo -e "\t\tSonarr items limit: ${SHSEEKARR_SONARR_LIMIT},") +$([ -n "${SHSEEKARR_SONARR_SEASONS_LIMIT}" ] && echo -e "\t\tSonarr seasons limit: ${SHSEEKARR_SONARR_SEASONS_LIMIT},") +$([ -n "${SHSEEKARR_RADARR_URL}" ] && echo -e "\t\tRadarr URL: ${SHSEEKARR_RADARR_URL},") +$([ -n "${SHSEEKARR_RADARR_APIKEY}" ] && echo -e "\t\tRadarr API Key: set,") +$([ -n "${SHSEEKARR_RADARR_LIMIT}" ] && echo -e "\t\tRadarr items limit: ${SHSEEKARR_RADARR_LIMIT},") +$([ -n "${SHSEEKARR_LIDARR_URL}" ] && echo -e "\t\tLidarr URL: ${SHSEEKARR_LIDARR_URL},") +$([ -n "${SHSEEKARR_LIDARR_APIKEY}" ] && echo -e "\t\tLidarr API Key: set,") +$([ -n "${SHSEEKARR_LIDARR_LIMIT}" ] && echo -e "\t\tLidarr items limit: ${SHSEEKARR_LIDARR_LIMIT},") +$([ -n "${SHSEEKARR_READARR_URL}" ] && echo -e "\t\tReadarr URL: ${SHSEEKARR_READARR_URL},") +$([ -n "${SHSEEKARR_READARR_APIKEY}" ] && echo -e "\t\tReadarr API Key: set,") +$([ -n "${SHSEEKARR_READARR_LIMIT}" ] && echo -e "\t\tReadarr items limit: ${SHSEEKARR_READARR_LIMIT}") " # ---- Sanity checks ---------------------------------------------------------- need_bin() { command -v "$1" >/dev/null 2>&1 || { - log ERROR "Missing required dependency: $1" >&2 + log ERROR "Missing required dependency: $1" exit 1 } } @@ -172,28 +216,28 @@ need_bin sed case "$SHSEEKARR_SEARCH_MODE" in missing | upgrades | both | all) ;; *) - log ERROR "Invalid SHSEEKARR_SEARCH_MODE: '${SHSEEKARR_SEARCH_MODE}' (expected missing|upgrades|both|all)" >&2 + log ERROR "Invalid SHSEEKARR_SEARCH_MODE: '${SHSEEKARR_SEARCH_MODE}' (expected missing|upgrades|both|all)" exit 1 ;; esac if ! [[ "$SHSEEKARR_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_LIMIT}'" >&2 + log ERROR "SHSEEKARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_LIMIT}'" exit 1 elif [[ -n "${SHSEEKARR_SONARR_LIMIT:-}" ]] && ! [[ "$SHSEEKARR_SONARR_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_SONARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_SONARR_LIMIT}'" >&2 + log ERROR "SHSEEKARR_SONARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_SONARR_LIMIT}'" exit 1 elif [[ -n "${SHSEEKARR_SONARR_SEASONS_LIMIT:-}" ]] && ! [[ "$SHSEEKARR_SONARR_SEASONS_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_SONARR_SEASONS_LIMIT must be a non-negative integer, got: '${SHSEEKARR_SONARR_SEASONS_LIMIT}'" >&2 + log ERROR "SHSEEKARR_SONARR_SEASONS_LIMIT must be a non-negative integer, got: '${SHSEEKARR_SONARR_SEASONS_LIMIT}'" exit 1 elif [[ -n "${SHSEEKARR_RADARR_LIMIT:-}" ]] && ! [[ "$SHSEEKARR_RADARR_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_RADARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_RADARR_LIMIT}'" >&2 + log ERROR "SHSEEKARR_RADARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_RADARR_LIMIT}'" exit 1 elif [[ -n "${SHSEEKARR_LIDARR_LIMIT:-}" ]] && ! [[ "$SHSEEKARR_LIDARR_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_LIDARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_LIDARR_LIMIT}'" >&2 + log ERROR "SHSEEKARR_LIDARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_LIDARR_LIMIT}'" exit 1 elif [[ -n "${SHSEEKARR_READARR_LIMIT:-}" ]] && ! [[ "$SHSEEKARR_READARR_LIMIT" =~ ^[0-9]+$ ]]; then - log ERROR "SHSEEKARR_READARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_READARR_LIMIT}'" >&2 + log ERROR "SHSEEKARR_READARR_LIMIT must be a non-negative integer, got: '${SHSEEKARR_READARR_LIMIT}'" exit 1 fi @@ -201,7 +245,7 @@ case "$(echo "$SHSEEKARR_MONITORED_ONLY" | tr '[:upper:]' '[:lower:]')" in true | 1 | yes) MONITORED_ONLY="true" ;; false | 0 | no) MONITORED_ONLY="false" ;; *) - log ERROR "Invalid SHSEEKARR_MONITORED_ONLY: '${SHSEEKARR_MONITORED_ONLY}' (expected true|false)" >&2 + log ERROR "Invalid SHSEEKARR_MONITORED_ONLY: '${SHSEEKARR_MONITORED_ONLY}' (expected true|false)" exit 1 ;; esac @@ -211,12 +255,17 @@ true | 1 | yes) SHSEEKARR_DRY_RUN="true" ;; *) SHSEEKARR_DRY_RUN="false" ;; esac +case "$(echo "$SHSEEKARR_INDEXER_TEST_ON_FAILURE" | tr '[:upper:]' '[:lower:]')" in +true | 1 | yes) SHSEEKARR_INDEXER_TEST_ON_FAILURE="true" ;; +*) SHSEEKARR_INDEXER_TEST_ON_FAILURE="false" ;; +esac + case "$(echo "$SHSEEKARR_SEARCH_ON_START" | tr '[:upper:]' '[:lower:]')" in true | 1 | yes) SHSEEKARR_SEARCH_ON_START="true" ;; *) SHSEEKARR_SEARCH_ON_START="false" ;; esac -if [[ ${SHSEEKARR_SEARCH_ON_START} == false ]] && [[ ! -n ${SHSEEKARR_SCHEDULE_INTERVAL} ]]; then +if [[ ${SHSEEKARR_SEARCH_ON_START} == false ]] && [[ -z ${SHSEEKARR_SCHEDULE_INTERVAL} ]]; then log WARNING "You disabled search on start, but didn't not set any Schedule Interval. Will ignore this setting" fi @@ -230,12 +279,14 @@ api_get() { } api_post_command() { - # $1=base_url $2=apikey $3=api_version $4=json body - curl -fsS --max-time 30 \ + # $1=base_url $2=apikey $3=api_version $4=json body $5=custom path (starting with /, e.g. /wanted/missing?...) + # Set default path if not set + local path="${5:-/command}" + curl -sS --max-time 45 \ -H "X-Api-Key: ${2}" \ -H "Content-Type: application/json" \ -X POST -d "${4}" \ - "${1%/}/api/${3}/command" + "${1%/}/api/${3}${path}" } api_version_for() { @@ -250,17 +301,38 @@ api_version_for() { connectivity_check() { # $1=base_url $2=apikey $3=api_version local connectivityCheck - connectivityCheck="$(curl -sL -m 3 --retry 1 -o /dev/null -w %{http_code} -H "X-Api-Key: ${2}" "${1%/}/api/${3}/wanted/missing?page=0&pageSize=1" 2>&1 || true )" + local http_code + local health_json + connectivityCheck="$(curl -sL -m 3 --retry 1 -w '\n%{http_code}' -H "X-Api-Key: ${2}" "${1%/}/api/${3}/health" 2>&1 || true )" + + http_code="${connectivityCheck##*$'\n'}" + health_json="${connectivityCheck%$'\n'*}" + + # Check if indexers have been failing long-term + if jq -e '.[] | select(.source == "IndexerLongTermStatusCheck" and .type == "warning")' <<< "$health_json" >/dev/null; then + indexers_failure="Long-term indexer failure detected" + if [[ "$SHSEEKARR_INDEXER_TEST_ON_FAILURE" == "true" ]]; then + log WARNING "$indexers_failure" + log INFO "Will force a retest of all indexers before search. This can take some time..." + if resp="$(api_post_command "$1" "$2" "$3" "" "/indexer/testall" 2>&1 >/dev/null)"; then + log INFO "All Indexers retest triggered successfully" + else + log WARNING "Failed to trigger indexer retest" + fi + else + log WARNING "$indexers_failure. You can force all indexers to be retested before the search request by setting 'SHSEEKARR_INDEXER_TEST_ON_FAILURE' to 'true'" + fi + fi - # This is success - [[ "$connectivityCheck" == "200" ]] && return + # This is success + [[ "$http_code" == "200" ]] && return - # This is an error - [[ "$connectivityCheck" == "400" ]] && { log ERROR "Bad Request"; exit 1; } - [[ "$connectivityCheck" == "401" ]] && { log ERROR "Unauthorized. Please check API Token"; exit 1; } - [[ "$connectivityCheck" == "404" ]] && { log ERROR "Not Found under ${1%/}/api/${3}"; exit 1; } - [[ "$connectivityCheck" == "500" ]] && { log ERROR "Server Error by calling ${1%/}/api/${3}"; exit 1 ; } - [[ "$connectivityCheck" == "000" ]] && { log ERROR "Host is not reachable. Please check if Server and Port are correct. Current config is ${1%/}"; exit 1 ; } + # This is an error + [[ "$http_code" == "400" ]] && { log ERROR "Bad Request"; exit 1; } + [[ "$http_code" == "401" ]] && { log ERROR "Unauthorized. Please check API Token"; exit 1; } + [[ "$http_code" == "404" ]] && { log ERROR "Not Found under ${1%/}/api/${3}"; exit 1; } + [[ "$http_code" == "500" ]] && { log ERROR "Server Error by calling ${1%/}/api/${3}"; exit 1 ; } + [[ "$http_code" == "000" ]] && { log ERROR "Host is not reachable. Please check if Server and Port are correct. Current config is ${1%/}"; exit 1 ; } } @@ -278,7 +350,7 @@ fetch_wanted_ids() { qs="?page=${page}&pageSize=${SHSEEKARR_PAGE_SIZE}&sortKey=id&sortDirection=ascending${extra_qs}&monitored=${MONITORED_ONLY}" if ! resp="$(api_get "$base_url" "$apikey" "$api_version" "/${endpoint}${qs}")"; then - log WARNING "Request to ${endpoint} (page ${page}) failed, stopping pagination." >&2 + log WARNING "Request to ${endpoint} (page ${page}) failed, stopping pagination." break fi @@ -304,7 +376,7 @@ fetch_wanted_ids() { } # ---- Per-app processing ------------------------------------------------------ - + # Unlike process_app(), Sonarr's SeasonSearch command does not accept a list of ids - it targets exactly one (seriesId, seasonNumber) pair per call: {"name":"SeasonSearch","seriesId":111,"seasonNumber":0} So instead of one batched POST, this fires one POST per selected season. process_app_trigger_execution() { @@ -315,22 +387,22 @@ process_app_trigger_execution() { # albumIds, bookIds, ...) so this doesn't need updating per-app; falls back # to 1 for id-less bodies like sonarr_seasons' {seriesId, seasonNumber}. actual_count="$(echo "$body" | jq -r '([.[] | select(type=="array") | length] | first) // 1')" - + if [[ "$SHSEEKARR_DRY_RUN" == "true" ]]; then log INFO "[DRY RUN] Would POST to ${app} /api/${api_version}/command:" echo "$body" | jq . return 0 fi - + log INFO "Triggering ${actual_name} on ${app} for ${actual_count} item(s)..." local resp if resp="$(api_post_command "$base_url" "$apikey" "$api_version" "$body")"; then log INFO "$(echo "$resp" | jq -r '{id, name, status} | to_entries | map("\(.key)=\(.value)") | join(" ")' 2>/dev/null)" else if [[ "$app" == "sonarr_seasons" ]]; then - log WARNING "Failed to trigger SeasonSearch for seriesId=${sel_series} seasonNumber=${sel_season}." >&2 + log WARNING "Failed to trigger SeasonSearch for seriesId=${sel_series} seasonNumber=${sel_season}." else - log WARNING "Failed to trigger search command for ${app}." >&2 + log WARNING "Failed to trigger search command for ${app}." fi fi } @@ -357,7 +429,7 @@ process_app() { local missing_file="${tmp_dir}/missing.txt" local cutoff_file="${tmp_dir}/cutoff.txt" - + # Fetching is identical for all three apps (sonarr, sonarr_seasons, radarr) - only the endpoint/extra_qs/record_jq passed in differ. This is what makes SHSEEKARR_SEARCH_MODE (missing/upgrades/both/all) apply consistently to sonarr_seasons too, instead of hardcoding wanted/missing. if [[ "$SHSEEKARR_SEARCH_MODE" == "missing" || "$SHSEEKARR_SEARCH_MODE" == "both" || "$SHSEEKARR_SEARCH_MODE" == "all" ]]; then log INFO "Fetching missing items for ${app}..." @@ -376,11 +448,11 @@ process_app() { local total_found total_found="$(wc -l <"$ids_file" | tr -d ' ')" - + if [[ "$app" == "sonarr_seasons" ]]; then log INFO "Found ${total_found} candidate season(s) for ${app} after filtering (monitoredOnly=${MONITORED_ONLY})." else - log INFO "Found ${total_found} candidate item(s) for ${app} after filtering (monitoredOnly=${MONITORED_ONLY})." + log INFO "Found ${total_found} candidate item(s) for ${app} after filtering (monitoredOnly=${MONITORED_ONLY})." fi if ((total_found == 0)); then @@ -411,25 +483,25 @@ process_app() { local selected_count selected_count="$(wc -l <"$selected_file" | tr -d ' ')" - + if [[ "$app" == "sonarr_seasons" ]]; then log INFO "Randomly selected ${selected_count} season(s) (limit=${LIMIT})." else - log INFO "Randomly selected ${selected_count} item(s) (limit=${LIMIT})." + log INFO "Randomly selected ${selected_count} item(s) (limit=${LIMIT})." fi # Is there is nothing to work with, exit this function if ((selected_count == 0)); then return 0 fi - + # Show what was picked, by name. if [[ "$app" == "sonarr_seasons" ]]; then local sel_series sel_season sel_label body resp - + while IFS=$'\t' read -r sel_series sel_season sel_label; do log INFO "Request - ${sel_label}" - + body="$(jq -n \ --argjson seriesId "$sel_series" \ --argjson seasonNumber "$sel_season" \ @@ -437,91 +509,90 @@ process_app() { process_app_trigger_execution done <"$selected_file" else - local sel_id sel_label - - while IFS=$'\t' read -r sel_id sel_label; do - # log INFO "${sel_id}\t- ${sel_label}" - log INFO "Request - ${sel_label}" - done <"$selected_file" - - local ids_json body - ids_json="$(cut -f1 "$selected_file" | jq -R -s -c 'split("\n") | map(select(length > 0) | tonumber)')" - body="$(jq -n \ - --arg name "$command_name" \ - --argjson ids "$ids_json" \ - --arg field "$id_field" \ - '{name: $name} + {($field): $ids}')" + local sel_label + + while IFS=$'\t' read -r sel_label; do + log INFO "Request - ${sel_label}" + done <"$selected_file" + + local ids_json body + ids_json="$(cut -f1 "$selected_file" | jq -R -s -c 'split("\n") | map(select(length > 0) | tonumber)')" + body="$(jq -n \ + --arg name "$command_name" \ + --argjson ids "$ids_json" \ + --arg field "$id_field" \ + '{name: $name} + {($field): $ids}')" process_app_trigger_execution fi } # ---- Main ------------------------------------------------------------------- - + main_app () { -# Sonarr's wanted endpoints only embed the parent series (needed for the series title) if includeSeries=true is requested. -SONARR_EXTRA_QS="&includeSeries=true" -# jq: [id, "Series Name S01E05"] as a 2-column @tsv line. Season/episode numbers are zero-padded to 2 digits (numbers >= 100 are left as-is). -SONARR_RECORD_JQ='[(.id|tostring), ((.series.title // "Unknown Series") + " S" + ((.seasonNumber|tostring) | if (length < 2) then "0" + . else . end) + "E" + ((.episodeNumber|tostring) | if (length < 2) then "0" + . else . end))] | @tsv' - -# jq: [seriesId, seasonNumber, "Series Name Season 01"] as a 3-column @tsv line, one per missing episode. -SONARR_SEASONS_RECORD_JQ='[(.seriesId|tostring), (.seasonNumber|tostring), ((.series.title // "Unknown Series") + " Season " + ((.seasonNumber|tostring) | if (length < 2) then "0" + . else . end))] | @tsv' - -# Radarr's wanted endpoints return MovieResource records directly, which already carry title/year - no extra query param needed. -RADARR_EXTRA_QS="" -# jq: [id, "Movie Title (Year)"] as a 2-column @tsv line. -RADARR_RECORD_JQ='[(.id|tostring), ((.title // "Unknown Movie") + " (" + ((.year // "?")|tostring) + ")")] | @tsv' - -# Lidarr's wanted endpoints only embed the parent artist (needed for the artist name) if includeArtist=true is requested. -LIDARR_EXTRA_QS="&includeArtist=true" -# jq: [id, "Artist Name - Album Title"] as a 2-column @tsv line. -LIDARR_RECORD_JQ='[(.id|tostring), ((.artist.artistName // "Unknown Artist") + " - " + (.title // "Unknown Album"))] | @tsv' - -# Readarr's wanted endpoints only embed the parent author (needed for the author name) if includeAuthor=true is requested. -READARR_EXTRA_QS="&includeAuthor=true" -# jq: [id, "Author Name - Book Title"] as a 2-column @tsv line. -READARR_RECORD_JQ='[(.id|tostring), ((.author.authorName // "Unknown Author") + " - " + (.title // "Unknown Book"))] | @tsv' - -IFS=',' read -ra APPS_ARR <<<"$SHSEEKARR_APPS" - -for raw_app in "${APPS_ARR[@]}"; do - app="$(echo "$raw_app" | xargs | tr '[:upper:]' '[:lower:]')" - case "$app" in - sonarr) - process_app "sonarr" "$SHSEEKARR_SONARR_URL" "$SHSEEKARR_SONARR_APIKEY" "$(api_version_for sonarr)" \ - "wanted/missing" "wanted/cutoff" "episodeIds" "EpisodeSearch" \ - "$SONARR_EXTRA_QS" "$SONARR_RECORD_JQ" - ;; - sonarr_seasons) - process_app "sonarr_seasons" "$SHSEEKARR_SONARR_URL" "$SHSEEKARR_SONARR_APIKEY" "$(api_version_for sonarr_seasons)" \ - "wanted/missing" "wanted/cutoff" "" "" \ - "$SONARR_EXTRA_QS" "$SONARR_SEASONS_RECORD_JQ" - ;; - radarr) - process_app "radarr" "$SHSEEKARR_RADARR_URL" "$SHSEEKARR_RADARR_APIKEY" "$(api_version_for radarr)" \ - "wanted/missing" "wanted/cutoff" "movieIds" "MoviesSearch" \ - "$RADARR_EXTRA_QS" "$RADARR_RECORD_JQ" - ;; - lidarr) - process_app "lidarr" "$SHSEEKARR_LIDARR_URL" "$SHSEEKARR_LIDARR_APIKEY" "$(api_version_for lidarr)" \ - "wanted/missing" "wanted/cutoff" "albumIds" "AlbumSearch" \ - "$LIDARR_EXTRA_QS" "$LIDARR_RECORD_JQ" - ;; - readarr) - process_app "readarr" "$SHSEEKARR_READARR_URL" "$SHSEEKARR_READARR_APIKEY" "$(api_version_for readarr)" \ - "wanted/missing" "wanted/cutoff" "bookIds" "BookSearch" \ - "$READARR_EXTRA_QS" "$READARR_RECORD_JQ" - ;; - "") ;; - *) - log ERROR "Unknown app in SHSEEKARR_APPS: '${app}' (expected all, or one of: sonarr,sonarr_seasons,radarr,lidarr,readarr)" >&2 - ;; - esac -done + # Sonarr's wanted endpoints only embed the parent series (needed for the series title) if includeSeries=true is requested. + SONARR_EXTRA_QS="&includeSeries=true" + # jq: [id, "Series Name S01E05"] as a 2-column @tsv line. Season/episode numbers are zero-padded to 2 digits (numbers >= 100 are left as-is). + SONARR_RECORD_JQ='[(.id|tostring), ((.series.title // "Unknown Series") + " S" + ((.seasonNumber|tostring) | if (length < 2) then "0" + . else . end) + "E" + ((.episodeNumber|tostring) | if (length < 2) then "0" + . else . end))] | @tsv' + + # jq: [seriesId, seasonNumber, "Series Name Season 01"] as a 3-column @tsv line, one per missing episode. + SONARR_SEASONS_RECORD_JQ='[(.seriesId|tostring), (.seasonNumber|tostring), ((.series.title // "Unknown Series") + " Season " + ((.seasonNumber|tostring) | if (length < 2) then "0" + . else . end))] | @tsv' + + # Radarr's wanted endpoints return MovieResource records directly, which already carry title/year - no extra query param needed. + RADARR_EXTRA_QS="" + # jq: [id, "Movie Title (Year)"] as a 2-column @tsv line. + RADARR_RECORD_JQ='[(.id|tostring), ((.title // "Unknown Movie") + " (" + ((.year // "?")|tostring) + ")")] | @tsv' + + # Lidarr's wanted endpoints only embed the parent artist (needed for the artist name) if includeArtist=true is requested. + LIDARR_EXTRA_QS="&includeArtist=true" + # jq: [id, "Artist Name - Album Title"] as a 2-column @tsv line. + LIDARR_RECORD_JQ='[(.id|tostring), ((.artist.artistName // "Unknown Artist") + " - " + (.title // "Unknown Album"))] | @tsv' + + # Readarr's wanted endpoints only embed the parent author (needed for the author name) if includeAuthor=true is requested. + READARR_EXTRA_QS="&includeAuthor=true" + # jq: [id, "Author Name - Book Title"] as a 2-column @tsv line. + READARR_RECORD_JQ='[(.id|tostring), ((.author.authorName // "Unknown Author") + " - " + (.title // "Unknown Book"))] | @tsv' + + IFS=',' read -ra APPS_ARR <<<"$SHSEEKARR_APPS" + + for raw_app in "${APPS_ARR[@]}"; do + app="$(echo "$raw_app" | xargs | tr '[:upper:]' '[:lower:]')" + case "$app" in + sonarr) + process_app "sonarr" "$SHSEEKARR_SONARR_URL" "$SHSEEKARR_SONARR_APIKEY" "$(api_version_for sonarr)" \ + "wanted/missing" "wanted/cutoff" "episodeIds" "EpisodeSearch" \ + "$SONARR_EXTRA_QS" "$SONARR_RECORD_JQ" + ;; + sonarr_seasons) + process_app "sonarr_seasons" "$SHSEEKARR_SONARR_URL" "$SHSEEKARR_SONARR_APIKEY" "$(api_version_for sonarr_seasons)" \ + "wanted/missing" "wanted/cutoff" "" "" \ + "$SONARR_EXTRA_QS" "$SONARR_SEASONS_RECORD_JQ" + ;; + radarr) + process_app "radarr" "$SHSEEKARR_RADARR_URL" "$SHSEEKARR_RADARR_APIKEY" "$(api_version_for radarr)" \ + "wanted/missing" "wanted/cutoff" "movieIds" "MoviesSearch" \ + "$RADARR_EXTRA_QS" "$RADARR_RECORD_JQ" + ;; + lidarr) + process_app "lidarr" "$SHSEEKARR_LIDARR_URL" "$SHSEEKARR_LIDARR_APIKEY" "$(api_version_for lidarr)" \ + "wanted/missing" "wanted/cutoff" "albumIds" "AlbumSearch" \ + "$LIDARR_EXTRA_QS" "$LIDARR_RECORD_JQ" + ;; + readarr) + process_app "readarr" "$SHSEEKARR_READARR_URL" "$SHSEEKARR_READARR_APIKEY" "$(api_version_for readarr)" \ + "wanted/missing" "wanted/cutoff" "bookIds" "BookSearch" \ + "$READARR_EXTRA_QS" "$READARR_RECORD_JQ" + ;; + "") ;; + *) + log ERROR "Unknown app in SHSEEKARR_APPS: '${app}' (expected all, or one of: sonarr,sonarr_seasons,radarr,lidarr,readarr)" + ;; + esac + done } # Wrapped to function to enable TA main() { -if [[ -n ${SHSEEKARR_SCHEDULE_INTERVAL} ]]; then + if [[ -n ${SHSEEKARR_SCHEDULE_INTERVAL} ]]; then random_sleep="" # If search on start disabled. skip it till next round @@ -535,24 +606,24 @@ if [[ -n ${SHSEEKARR_SCHEDULE_INTERVAL} ]]; then # Infinity loop with periodical check while : do - # Add randomizer between 1 and 3600 seconds - if [[ ${SHSEEKARR_SCHEDULE_RANDOMIZER} == "true" ]]; then - random_sleep="$(echo $((1 + $RANDOM % 3600)))" - fi - app="main" - log INFO "Will sleep for a $SHSEEKARR_SCHEDULE_INTERVAL $([ -n "${random_sleep:-}" ] && echo "plus ${random_sleep} seconds")" - sleep ${SHSEEKARR_SCHEDULE_INTERVAL} ${random_sleep} - main_app + # Add randomizer between 1 and 3600 seconds + if [[ ${SHSEEKARR_SCHEDULE_RANDOMIZER} == "true" ]]; then + random_sleep="$((1 + RANDOM % 3600))" + fi + app="main" + log INFO "Will sleep for a $SHSEEKARR_SCHEDULE_INTERVAL $([ -n "${random_sleep:-}" ] && echo "plus ${random_sleep} seconds")" + sleep "${SHSEEKARR_SCHEDULE_INTERVAL}" "${random_sleep}" + main_app done -else - main_app -fi + else + main_app + fi } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main -app="main" -log INFO "All done." -exit 0 + app="main" + log INFO "All done." + exit 0 fi diff --git a/test/connectivity_check.bats b/test/connectivity_check.bats index 2a33dbc..4400750 100755 --- a/test/connectivity_check.bats +++ b/test/connectivity_check.bats @@ -62,4 +62,60 @@ setup() { run connectivity_check http://localhost key v3 [ "$status" -eq 1 ] +} + +@test "Connectivity check retests indexers when failure test is enabled" { + SHSEEKARR_INDEXER_TEST_ON_FAILURE=true + local marker="$BATS_TEST_TMPDIR/indexer_retest" + + curl() { + cat <<'EOF' +[ + { + "source": "IndexerLongTermStatusCheck", + "type": "warning", + "message": "Indexers unavailable due to failures for more than 6 hours: Indexer" + } +] +200 +EOF + } + + api_post_command() { + touch "$marker" + return 0 + } + + run connectivity_check http://localhost key v3 + + [ "$status" -eq 0 ] + [ -f "$marker" ] +} + +@test "Connectivity check does not retest indexers when failure test is disabled" { + SHSEEKARR_INDEXER_TEST_ON_FAILURE=false + local marker="$BATS_TEST_TMPDIR/indexer_retest" + + curl() { + cat <<'EOF' +[ + { + "source": "IndexerLongTermStatusCheck", + "type": "warning", + "message": "Indexers unavailable due to failures for more than 6 hours: Indexer" + } +] +200 +EOF + } + + api_post_command() { + touch "$marker" + return 0 + } + + run connectivity_check http://localhost key v3 + + [ "$status" -eq 0 ] + [ ! -f "$marker" ] } \ No newline at end of file diff --git a/test/log.bats b/test/log.bats new file mode 100644 index 0000000..3f16151 --- /dev/null +++ b/test/log.bats @@ -0,0 +1,207 @@ +#!/usr/bin/env bats + +SCRIPT="./sh-seekarr.sh" + +load_script() { + export SHSEEKARR_LOG_FORMAT="$1" + export SHSEEKARR_SCHEDULE_INTERVAL="" + + source "$SCRIPT" + + app="test-app" + + # Deterministic timestamp. + ts() { + if [[ "$SHSEEKARR_LOG_FORMAT" == "json" ]]; then + echo "2026-08-10T12:34:56.000+00:00" + else + echo "2026-08-10 12:34:56" + fi + } +} + +@test "log INFO goes to stdout in text format" { + load_script text + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log INFO "hello world" >"$stdout" 2>"$stderr" + + [ "$(cat "$stdout")" = \ + "2026-08-10 12:34:56 - INFO - test-app - hello world" ] + + [ ! -s "$stderr" ] +} + + +@test "log DEBUG goes to stdout in text format" { + load_script text + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log DEBUG "debug message" >"$stdout" 2>"$stderr" + + [ "$(cat "$stdout")" = \ + "2026-08-10 12:34:56 - DEBUG - test-app - debug message" ] + + [ ! -s "$stderr" ] +} + + +@test "log WARNING goes to stderr in text format" { + load_script text + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log WARNING "something looks wrong" >"$stdout" 2>"$stderr" + + [ ! -s "$stdout" ] + + [ "$(cat "$stderr")" = \ + "2026-08-10 12:34:56 - WARNING - test-app - something looks wrong" ] +} + + +@test "log ERROR goes to stderr in text format" { + load_script text + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log ERROR "something failed" >"$stdout" 2>"$stderr" + + [ ! -s "$stdout" ] + + [ "$(cat "$stderr")" = \ + "2026-08-10 12:34:56 - ERROR - test-app - something failed" ] +} + + +@test "log INFO produces valid JSON" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log INFO "hello world" >"$stdout" 2>"$stderr" + + [ ! -s "$stderr" ] + + jq -e \ + ' + .component == "test-app" and + .level == "INFO" and + .msg == "hello world" and + .time == "2026-08-10T12:34:56.000+00:00" + ' "$stdout" +} + + +@test "log ERROR produces JSON on stderr" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log ERROR "something failed" >"$stdout" 2>"$stderr" + + [ ! -s "$stdout" ] + + jq -e \ + ' + .component == "test-app" and + .level == "ERROR" and + .msg == "something failed" and + .time == "2026-08-10T12:34:56.000+00:00" + ' "$stderr" +} + + +@test "log WARNING produces JSON on stderr" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log WARNING "warning message" >"$stdout" 2>"$stderr" + + [ ! -s "$stdout" ] + + jq -e \ + ' + .component == "test-app" and + .level == "WARNING" and + .msg == "warning message" and + .time == "2026-08-10T12:34:56.000+00:00" + ' "$stderr" +} + + +@test "log JSON preserves special characters" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log INFO 'hello "world" / test' >"$stdout" 2>"$stderr" + + jq -e '.msg == "hello \"world\" / test"' "$stdout" +} + +@test "log JSON normalizes whitespace" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log INFO $'hello world\nthis\tis a test' >"$stdout" 2>"$stderr" + + jq -e '.msg == "hello world this is a test"' "$stdout" + +} + +@test "log component comes from app" { + load_script json + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + app="sonarr" + + log INFO "searching" >"$stdout" 2>"$stderr" + + jq -e ' + .component == "sonarr" and + .level == "INFO" and + .msg == "searching" + ' "$stdout" +} + + +@test "log multiline text removes empty lines" { + load_script text + + stdout="$(mktemp)" + stderr="$(mktemp)" + trap 'rm -f "$stdout" "$stderr"' EXIT + + log INFO $'first\n\n\nsecond' >"$stdout" 2>"$stderr" + + expected=$'2026-08-10 12:34:56 - INFO - test-app - first\nsecond' + + [ "$(cat "$stdout")" = "$expected" ] + [ ! -s "$stderr" ] +} \ No newline at end of file