diff --git a/Dockerfile b/Dockerfile index d7e2f44..9cb1158 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,16 +7,18 @@ ARG USER=nobody LABEL maintainer="Georgiy Sitnikov " \ org.opencontainers.image.title="sh-seekarr" \ - org.opencontainers.image.description="SH implementation of Seekarr as alternative to Huntarr. A lightweight tool that triggers manual searches in Sonarr and Radarr to find missing items and upgrade existing ones to better quality. No UI. No exposed ports. Queries Sonarr and/or Radarr for missing and/or cutoff-unmet items" \ + org.opencontainers.image.description="SH implementation of Seekarr as alternative to Huntarr. A lightweight tool that triggers manual searches in Sonarr and Radarr to find missing items and upgrade existing ones to better quality. No UI. No exposed ports. Queries Sonarr and/or Radarr for missing and/or cutoff-unmet items. Minimal CPU and RAM footprint." \ org.opencontainers.image.source="https://github.com/GAS85/sh-seekarr" \ org.opencontainers.image.url="https://hub.docker.com/r/gas85/sh-seekarr" \ org.opencontainers.image.documentation="https://github.com/GAS85/sh-seekarr#" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.version=$VERSION \ - org.opencontainers.image.revision=$VCS_REF + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.created=$BUILD_DATE ENV VERSION=$VERSION ENV VCS_REF=$VCS_REF +ENV BUILD_DATE=$BUILD_DATE ENV TZ=Europe/Zurich RUN apk add --no-cache curl jq coreutils diff --git a/README.md b/README.md index f0eaccd..1633775 100644 --- a/README.md +++ b/README.md @@ -79,23 +79,26 @@ All configuration is via environment variables, prefixed `SHSEEKARR_`. | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| -| `SHSEEKARR_APPS` | `sonarr,radarr` | `sonarr,radarr` | Comma-separated list of apps to run. Any combination of `sonarr` and `radarr`. | -| `SHSEEKARR_SONARR_URL` | If using Sonarr | *(empty)* | Base URL of your Sonarr instance. E.g.: `http://sonarr:8989` | -| `SHSEEKARR_SONARR_APIKEY` | If using Sonarr | *(empty)* | Sonarr API key (Settings → General). | +| `SHSEEKARR_APPS` | `sonarr,radarr,sonarr_seasons` | `sonarr,radarr` | Comma-separated list of apps to run. Any combination of `sonarr`, `sonarr_seasons` and `radarr`.
`sonarr` will request random Episodes from a different Series and Seasons.
`sonarr_seasons` will request whole Season from random Series if at least 1 episode is missing from it. | +| `SHSEEKARR_SONARR_URL` | If using Sonarr | *(empty)* | Base URL of your Sonarr instance. E.g.: `http://sonarr:8989`. It is needed for `sonarr` and `sonarr_seasons` apps. | +| `SHSEEKARR_SONARR_APIKEY` | If using Sonarr | *(empty)* | Sonarr API key (Settings → General). It is needed for `sonarr` and `sonarr_seasons` apps. | | `SHSEEKARR_RADARR_URL` | If using Radarr | *(empty)* | Base URL of your Radarr instance. E.g.: `http://radarr:7878`| | `SHSEEKARR_RADARR_APIKEY` | If using Radarr | *(empty)* | Radarr API key (Settings → General). | If an app's URL or API key isn't set, that app is skipped with a log message rather than causing an error — so `SHSEEKARR_APPS` can safely list an app you haven't configured yet. +Why `sonarr` and `sonarr_seasons` exist, are they doing same job? Sometimes, especially when Season was fully released it is easer to search for a whole season, instead of particular episode, this is not covered by `sonarr` only. + ### Search behavior | Variable | Default | Description | |----------|---------|-------------| | `SHSEEKARR_SEARCH_MODE` | `missing` | `missing` — only unaired/missing items (`wanted/missing`).
`upgrades` — only cutoff-unmet items (`wanted/cutoff`).
`both` / `all` — union of both, deduplicated by id. | -| `SHSEEKARR_MONITORED_ONLY` | `true` | `true`/`false` (also accepts `1`/`0`, `yes`/`no`). If `true`, only monitored items are considered. | +| `SHSEEKARR_MONITORED_ONLY` | `true` | `true`/`false` (also accepts `1`/`0`, `yes`/`no`). If `true`, only monitored items are considered. Usually it is not needed to set it to `false`, in this case we will search over the whole catalog of series and movies, even they are not monitored (probable already watched). | | `SHSEEKARR_LIMIT` | `10` | Max number of items to search, **per app**, after random selection. | | `SHSEEKARR_SONARR_LIMIT` | *(unset)* | If set, overrides `SHSEEKARR_LIMIT` for Sonarr only. | | `SHSEEKARR_RADARR_LIMIT` | *(unset)* | If set, overrides `SHSEEKARR_LIMIT` for Radarr 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. | **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. @@ -121,7 +124,7 @@ SHSEEKARR_APPS="sonarr" \ SHSEEKARR_SONARR_URL="http://localhost:8989" \ SHSEEKARR_SONARR_APIKEY="xxxx" \ SHSEEKARR_SEARCH_MODE="upgrades" \ -SHSEEKARR_LIMIT="10" \ +SHSEEKARR_LIMIT="15" \ ./seekarr.sh ``` @@ -174,13 +177,14 @@ Don't hardcode API keys directly in the script. Keep them in environment variabl ### cron ```cron -# Every 4 hours -0 */4 * * * /path/to/seekarr.sh >> /var/log/seekarr.log 2>&1 +# Every 6 hours +0 */6 * * * /path/to/seekarr.sh >> /var/log/seekarr.log 2>&1 ``` ### systemd timer `seekarr.service`: + ```ini [Unit] Description=seekarr search trigger @@ -192,12 +196,13 @@ ExecStart=/path/to/seekarr.sh ``` `seekarr.timer`: + ```ini [Unit] -Description=Run seekarr every 4 hours +Description=Run seekarr every 6 hours [Timer] -OnCalendar=*-*-* 0/4:00:00 +OnCalendar=*-*-* 0/6:00:00 Persistent=true [Install] @@ -205,7 +210,8 @@ WantedBy=timers.target ``` `/etc/seekarr.env`: -``` + +```bash SHSEEKARR_SONARR_URL=http://localhost:8989 SHSEEKARR_SONARR_APIKEY=xxxx SHSEEKARR_RADARR_URL=http://localhost:7878 @@ -219,11 +225,11 @@ export SHSEEKARR_SONARR_URL="http://localhost:8989" export SHSEEKARR_SONARR_APIKEY="your-sonarr-api-key" export SHSEEKARR_RADARR_URL="http://localhost:7878" export SHSEEKARR_RADARR_APIKEY="your-radarr-api-key" -export SHSEEKARR_SCHEDULE_INTERVAL="4h" +export SHSEEKARR_SCHEDULE_INTERVAL="6h" ./seekarr.sh ``` -### Docker +### Docker internal scheduler ```bash docker run --name sh-seekarr \ @@ -231,10 +237,12 @@ docker run --name sh-seekarr \ -e "SHSEEKARR_SONARR_APIKEY=your key" \ -e SHSEEKARR_RADARR_URL=http://radarr:7878/ \ -e "SHSEEKARR_RADARR_APIKEY=your key" \ - -e "SHSEEKARR_SCHEDULE_INTERVAL=4h" \ + -e "SHSEEKARR_SCHEDULE_INTERVAL=6h" \ gas85/sh-seekarr:latest ``` +For docker compose, pleaser refer to [docker-compose.yml](https://github.com/GAS85/sh-seekarr/blob/main/docker-compose.yml). + ## Troubleshooting - **"Missing required dependency: jq"** — install `jq` (e.g. `apt install jq`, `brew install jq`). diff --git a/docker-compose.yml b/docker-compose.yml index be5b816..211b4c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,19 +3,20 @@ services: image: gas85/sh-seekarr:latest container_name: sh-seekarr environment: - - SHSEEKARR_APPS=sonarr,radarr - - SHSEEKARR_SEARCH_MODE=missing - - SHSEEKARR_MONITORED_ONLY=true - - SHSEEKARR_LIMIT=10 - - SHSEEKARR_PAGE_SIZE=200 - - SHSEEKARR_DRY_RUN=false - - SHSEEKARR_LOG_FORMAT=text - - SHSEEKARR_SCHEDULE_INTERVAL=6h - - SHSEEKARR_SCHEDULE_RANDOMIZER=true - - SHSEEKARR_SONARR_LIMIT=10 - - SHSEEKARR_RADARR_LIMIT=1 - SHSEEKARR_SONARR_URL=http://sonarr:8989/ - SHSEEKARR_SONARR_APIKEY=your key - SHSEEKARR_RADARR_URL=http://radarr:7878/ - SHSEEKARR_RADARR_APIKEY=your key - restart: no + # - SHSEEKARR_APPS=sonarr,radarr,sonarr_seasons + # - SHSEEKARR_SEARCH_MODE=missing + # - SHSEEKARR_MONITORED_ONLY=true + # - SHSEEKARR_LIMIT=10 + # - SHSEEKARR_PAGE_SIZE=200 + # - SHSEEKARR_DRY_RUN=true + # - SHSEEKARR_LOG_FORMAT=text + # - SHSEEKARR_SCHEDULE_INTERVAL=6h + # - SHSEEKARR_SCHEDULE_RANDOMIZER=true + # - SHSEEKARR_SONARR_LIMIT=10 + # - SHSEEKARR_SONARR_SEASONS_LIMIT=2 + # - SHSEEKARR_RADARR_LIMIT=1 + restart: no # Set to "unless-stopped" if you using SHSEEKARR_SCHEDULE_INTERVAL diff --git a/sh-seekarr.sh b/sh-seekarr.sh index c266e51..4b2f1a5 100755 --- a/sh-seekarr.sh +++ b/sh-seekarr.sh @@ -72,15 +72,18 @@ SHSEEKARR_LOG_FORMAT="${SHSEEKARR_LOG_FORMAT:-text}" # "text" or "json" SHSEEKARR_SONARR_URL="${SHSEEKARR_SONARR_URL:-}" SHSEEKARR_SONARR_APIKEY="${SHSEEKARR_SONARR_APIKEY:-}" SHSEEKARR_SONARR_LIMIT="${SHSEEKARR_SONARR_LIMIT:-}" +SHSEEKARR_SONARR_SEASONS_LIMIT="${SHSEEKARR_SONARR_SEASONS_LIMIT:-}" SHSEEKARR_RADARR_URL="${SHSEEKARR_RADARR_URL:-}" SHSEEKARR_RADARR_APIKEY="${SHSEEKARR_RADARR_APIKEY:-}" SHSEEKARR_RADARR_LIMIT="${SHSEEKARR_RADARR_LIMIT:-}" +# ---- Help Variables --------------------------------------------------------- + VERSION="${VERSION:-}" VCS_REF="${VCS_REF:-}" -# ---- Logging -------------------------------------------------------------- +# ---- Logging ---------------------------------------------------------------- app="main" @@ -115,6 +118,7 @@ $([ "${SHSEEKARR_SCHEDULE_RANDOMIZER}" = "true" ] && echo "\t\tScheduler randomi $([ -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}") @@ -146,6 +150,9 @@ if ! [[ "$SHSEEKARR_LIMIT" =~ ^[0-9]+$ ]]; then 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 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 + 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 exit 1 @@ -213,10 +220,7 @@ fetch_wanted_ids() { ConnectivityCheck "$base_url" "$apikey" while (((page - 1) * SHSEEKARR_PAGE_SIZE < total_records)); do - qs="?page=${page}&pageSize=${SHSEEKARR_PAGE_SIZE}&sortKey=id&sortDirection=ascending${extra_qs}" - if [[ "$MONITORED_ONLY" == "true" ]]; then - qs+="&monitored=true" - fi + qs="?page=${page}&pageSize=${SHSEEKARR_PAGE_SIZE}&sortKey=id&sortDirection=ascending${extra_qs}&monitored=${MONITORED_ONLY}" if ! resp="$(api_get "$base_url" "$apikey" "/${endpoint}${qs}")"; then log WARNING "Request to ${endpoint} (page ${page}) failed, stopping pagination." >&2 @@ -245,6 +249,33 @@ 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() { +# Derives the command name and item count from $body itself (via jq) rather than trusting the caller's $command_name/$selected_count - those are stale/wrong for sonarr_seasons (still "EpisodeSearch" from the dispatch call, and selected_count is the *total* across all seasons, not "1" for this single call). Reading them back out of $body can't drift from what's actually being sent. + local actual_name actual_count + actual_name="$(echo "$body" | jq -r '.name')" + actual_count="$(echo "$body" | jq -r 'if has("episodeIds") then (.episodeIds|length) elif has("movieIds") then (.movieIds|length) else 1 end')" + + if [[ "$SHSEEKARR_DRY_RUN" == "true" ]]; then + log INFO "[DRY RUN] Would POST to ${app} /api/v3/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" "$body")"; then + log INFO "$(echo "$resp" | jq -c '{id, name, status}' 2>/dev/null)" + else + if [[ "$app" == "sonarr_seasons" ]]; then + log WARNING "Failed to trigger SeasonSearch for seriesId=${sel_series} seasonNumber=${sel_season}." >&2 + else + log WARNING "Failed to trigger search command for ${app}." >&2 + fi + fi +} process_app() { local app="$1" base_url="$2" apikey="$3" @@ -256,8 +287,6 @@ process_app() { return 0 fi -# log INFO "== ${app} ==" - local tmp_dir tmp_dir="$(mktemp -d)" # shellcheck disable=SC2064 @@ -268,23 +297,33 @@ process_app() { local ids_file="${tmp_dir}/ids.txt" : >"$ids_file" + 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}..." - fetch_wanted_ids "$base_url" "$apikey" "$missing_endpoint" "${tmp_dir}/missing.txt" "$extra_qs" "$record_jq" - cat "${tmp_dir}/missing.txt" >>"$ids_file" + fetch_wanted_ids "$base_url" "$apikey" "$missing_endpoint" "$missing_file" "$extra_qs" "$record_jq" + cat "$missing_file" >>"$ids_file" fi if [[ "$SHSEEKARR_SEARCH_MODE" == "upgrades" || "$SHSEEKARR_SEARCH_MODE" == "both" || "$SHSEEKARR_SEARCH_MODE" == "all" ]]; then log INFO "Fetching cutoff-unmet (upgrade) items for ${app}..." - fetch_wanted_ids "$base_url" "$apikey" "$cutoff_endpoint" "${tmp_dir}/cutoff.txt" "$extra_qs" "$record_jq" - cat "${tmp_dir}/cutoff.txt" >>"$ids_file" + fetch_wanted_ids "$base_url" "$apikey" "$cutoff_endpoint" "$cutoff_file" "$extra_qs" "$record_jq" + cat "$cutoff_file" >>"$ids_file" fi + # Here we will get uniq records and Randomize order of them sort -uR "$ids_file" -o "$ids_file" 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})." + fi if ((total_found == 0)); then log INFO "Nothing to search for ${app}." @@ -293,27 +332,51 @@ process_app() { local LIMIT + # Apply Local app Limits if applicable, or use generic one if [[ "$app" == "sonarr" ]] && [[ -n "${SHSEEKARR_SONARR_LIMIT:-}" ]]; then LIMIT="${SHSEEKARR_SONARR_LIMIT}" elif [[ "$app" == "radarr" ]] && [[ -n "${SHSEEKARR_RADARR_LIMIT:-}" ]]; then LIMIT="${SHSEEKARR_RADARR_LIMIT}" + elif [[ "$app" == "sonarr_seasons" ]] && [[ -n "${SHSEEKARR_SONARR_SEASONS_LIMIT:-}" ]]; then + LIMIT="${SHSEEKARR_SONARR_SEASONS_LIMIT}" else LIMIT="${SHSEEKARR_LIMIT}" fi + # Select Items to search based on a limit from randomized list local selected_file="${tmp_dir}/selected.txt" head -n "$LIMIT" "$ids_file" >"$selected_file" local selected_count selected_count="$(wc -l <"$selected_file" | tr -d ' ')" - log INFO "Randomly selected ${selected_count} item(s) (limit=${SHSEEKARR_LIMIT})." + + 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})." + 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" \ + '{name: "SeasonSearch", seriesId: $seriesId, seasonNumber: $seasonNumber}')" + 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}" @@ -326,19 +389,7 @@ process_app() { --argjson ids "$ids_json" \ --arg field "$id_field" \ '{name: $name} + {($field): $ids}')" - - if [[ "$SHSEEKARR_DRY_RUN" == "true" ]]; then - log INFO "[DRY RUN] Would POST to ${app} /api/v3/command:" - echo "$body" | jq . - return 0 - fi - - log INFO "Triggering ${command_name} on ${app} for ${selected_count} item(s)..." - local resp - if resp="$(api_post_command "$base_url" "$apikey" "$body")"; then - log INFO "$(echo "$resp" | jq -c '{id, name, status}' 2>/dev/null)" - else - log WARNING "Failed to trigger search command for ${app}." >&2 + process_app_trigger_execution fi } @@ -350,6 +401,9 @@ 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. @@ -365,6 +419,11 @@ for raw_app in "${APPS_ARR[@]}"; do "wanted/missing" "wanted/cutoff" "episodeIds" "EpisodeSearch" \ "$SONARR_EXTRA_QS" "$SONARR_RECORD_JQ" ;; + sonarr_seasons) + process_app "sonarr_seasons" "$SHSEEKARR_SONARR_URL" "$SHSEEKARR_SONARR_APIKEY" \ + "wanted/missing" "wanted/cutoff" "" "" \ + "$SONARR_EXTRA_QS" "$SONARR_SEASONS_RECORD_JQ" + ;; radarr) process_app "radarr" "$SHSEEKARR_RADARR_URL" "$SHSEEKARR_RADARR_APIKEY" \ "wanted/missing" "wanted/cutoff" "movieIds" "MoviesSearch" \ @@ -372,7 +431,7 @@ for raw_app in "${APPS_ARR[@]}"; do ;; "") ;; *) - log ERROR "Unknown app in SHSEEKARR_APPS: '${app}' (expected sonarr and/or radarr)" >&2 + log ERROR "Unknown app in SHSEEKARR_APPS: '${app}' (expected all, or one of: sonarr,sonarr_seasons,radarr)" >&2 ;; esac done