From e11ac87d7a5e3df2604349edeeb436c7fbf176cd Mon Sep 17 00:00:00 2001 From: soulis-1256 Date: Fri, 14 Aug 2026 11:45:10 +0300 Subject: [PATCH] Fix Jellyfin empty-series after Sonarr season-pack imports Fire Sonarr refresh once on import complete, skip a second /Library/Refresh so it cannot cancel a running scan, and heal series that still show 0 episodes despite video on disk. --- lib/jellyfin.sh | 38 ++++--- scripts/jellyfin-refresh.sh | 167 +++++++++++++++++++++++++--- tests/run.sh | 1 + tests/unit/test_jellyfin_refresh.sh | 92 +++++++++++++++ 4 files changed, 269 insertions(+), 29 deletions(-) create mode 100755 tests/unit/test_jellyfin_refresh.sh diff --git a/lib/jellyfin.sh b/lib/jellyfin.sh index 9ff4e3d..2944257 100644 --- a/lib/jellyfin.sh +++ b/lib/jellyfin.sh @@ -399,8 +399,11 @@ configure_jellyfin_notifications() { # NOTE: The MediaBrowser/Emby notification type calls /Library/Media/Updated which # only refreshes EXISTING items in Jellyfin's DB — it cannot discover NEW files. - # We use a CustomScript that calls POST /Library/Refresh instead, which performs a - # full library scan and discovers newly downloaded movies/episodes. + # CustomScript → jellyfin-refresh.sh POSTs /Library/Refresh (full scan). + # A second POST cancels the in-progress scan; Jellyfin 10.11 can then leave a + # new series with 0 children in the UI. Sonarr fires onImportComplete (once per + # release, not per file). Radarr has no onImportComplete, so onDownload is one movie. + # The script also skips if a scan is already running and heals empty series. local errors=0 if ! add_jellyfin_refresh_notif "Radarr" "7878" "$RADARR_API_KEY"; then @@ -423,23 +426,22 @@ add_jellyfin_refresh_notif() { return 0 fi - local notifications + local notifications existing_id schema payload result notifications=$(api_get "$port" "/api/v3/notification" "$apikey") - local existing_id existing_id=$(echo "$notifications" | jq -r '.[] | select(.implementation == "CustomScript" and .name == "Jellyfin Refresh") | .id // ""' 2>/dev/null | head -1 || echo "") - if [ -n "$existing_id" ]; then - log_step "${app_name}: Jellyfin Refresh script already configured" - return 0 - fi - - local schema schema=$(api_get "$port" "/api/v3/notification/schema" "$apikey") - local payload + # Sonarr 4: onImportComplete fires once per release (season pack). Radarr has + # no onImportComplete — keep onDownload (one event per movie). payload=$(echo "$schema" | jq ' [.[] | select(.implementation == "CustomScript")][0] | .fields = [.fields[] | if .name == "path" then .value = "/scripts/jellyfin-refresh.sh" else . end] | - .onDownload = true | .onUpgrade = true | .name = "Jellyfin Refresh" | .tags = [] + .onUpgrade = true | .name = "Jellyfin Refresh" | .tags = [] | + if has("onImportComplete") then + .onDownload = false | .onImportComplete = true + else + .onDownload = true + end ' 2>/dev/null || echo "") if [ -z "$payload" ]; then @@ -454,10 +456,18 @@ add_jellyfin_refresh_notif() { chmod +x "$INSTALL_DIR/scripts/jellyfin-refresh.sh" 2>/dev/null || true fi - local result + if [ -n "$existing_id" ]; then + payload=$(echo "$payload" | jq --argjson id "$existing_id" '.id = $id' 2>/dev/null || echo "$payload") + result=$(api_put "$port" "/api/v3/notification/${existing_id}" "$apikey" "$payload") + if jq_json_has_key "$result" "id"; then + log_step "${app_name}: updated Jellyfin Refresh script (scan on import complete)" + return 0 + fi + fi + result=$(api_post_force "$port" "/api/v3/notification" "$apikey" "$payload") if jq_json_has_key "$result" "id"; then - log_step "${app_name}: added Jellyfin Refresh script (auto-scan on import)" + log_step "${app_name}: added Jellyfin Refresh script (scan on import complete)" return 0 fi local err_msg diff --git a/scripts/jellyfin-refresh.sh b/scripts/jellyfin-refresh.sh index 8682c51..ae84553 100755 --- a/scripts/jellyfin-refresh.sh +++ b/scripts/jellyfin-refresh.sh @@ -1,21 +1,134 @@ #!/bin/sh -# Triggered by Radarr/Sonarr on movie/episode import. -# Calls Jellyfin's full library refresh to discover new files. -# /Library/Media/Updated only refreshes EXISTING items — it cannot -# discover new ones. /Library/Refresh performs a full scan. +# Triggered by Radarr/Sonarr after import (see lib/jellyfin.sh). +# +# POST /Library/Refresh discovers new files. A second POST cancels the +# in-progress scan. Jellyfin 10.11 can then save a new series under a TVDB +# PresentationUniqueKey while its episodes keep the series GUID as +# SeriesPresentationUniqueKey — the UI shows 0 episodes / 0 minutes even +# though the files are in the DB. Do not cancel a running scan; after the +# scan goes idle, recursively refresh any series that still look empty +# despite video files on disk. JF_HOST="${JELLYFIN_HOST:-jellyfin}" JF_PORT="${JELLYFIN_PORT:-8096}" +JF_REFRESH_HEAL_POLL_SEC="${JF_REFRESH_HEAL_POLL_SEC:-5}" +JF_REFRESH_HEAL_MAX_POLLS="${JF_REFRESH_HEAL_MAX_POLLS:-36}" +JF_HEAL_LOCK_DIR="${JF_HEAL_LOCK_DIR:-/tmp/assemblrr-jf-heal.lock}" + +jf_refresh_is_test_event() { + [ "${radarr_eventtype:-}" = "Test" ] || [ "${sonarr_eventtype:-}" = "Test" ] +} + +# Arg: GET /ScheduledTasks JSON. Echo Idle when missing or unparseable. +jf_refresh_scan_state() { + local state + [ -n "${1:-}" ] || { echo Idle; return 0; } + state=$(printf '%s' "$1" | jq -r \ + '([.[]? | select(.Key == "RefreshLibrary") | .State] | first) // "Idle"' \ + 2>/dev/null || true) + [ -n "$state" ] && [ "$state" != "null" ] || state=Idle + echo "$state" +} + +jf_refresh_scan_busy() { + case "${1:-}" in + Running|Cancelling) return 0 ;; + *) return 1 ;; + esac +} + +# True when the Shows API reports no episodes but the library folder has video. +# That is the 10.11 identity-key miss (series page empty; item-by-id still works). +jf_refresh_dir_has_video() { + local dir="${1:-}" + [ -n "$dir" ] && [ -d "$dir" ] || return 1 + find "$dir" \( -iname '*.mkv' -o -iname '*.mp4' -o -iname '*.m4v' \ + -o -iname '*.avi' -o -iname '*.ts' -o -iname '*.m2ts' -o -iname '*.wmv' \) \ + -type f -print 2>/dev/null | head -1 | grep -q . +} + +jf_refresh_empty_series_ids() { + printf '%s' "${1:-}" | jq -r '.Items[]? | .Id // empty' 2>/dev/null +} + +if [ "${JF_REFRESH_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi -# Read API key from mounted secrets file (preferred) or env var (fallback) if [ -f "/run/secrets/jellyfin_api_key.txt" ]; then JF_API_KEY=$(cat /run/secrets/jellyfin_api_key.txt) else JF_API_KEY="${JELLYFIN_API_KEY:-}" fi -# Ignore the Test event (Radarr sends this when clicking "Test") -if [ "${radarr_eventtype}" = "Test" ] || [ "${sonarr_eventtype}" = "Test" ]; then +jf_refresh_curl() { + curl -sS --connect-timeout 5 --max-time 30 \ + -H "X-Emby-Token: ${JF_API_KEY}" \ + "$@" 2>/dev/null +} + +jf_refresh_heal_after_scan() { + local i=0 + local tasks state series_json sid path eps + + while [ "$i" -lt "$JF_REFRESH_HEAL_MAX_POLLS" ]; do + tasks=$(jf_refresh_curl "http://${JF_HOST}:${JF_PORT}/ScheduledTasks" || echo "") + state=$(jf_refresh_scan_state "$tasks") + if ! jf_refresh_scan_busy "$state"; then + break + fi + sleep "$JF_REFRESH_HEAL_POLL_SEC" + i=$((i + 1)) + done + + series_json=$(jf_refresh_curl \ + "http://${JF_HOST}:${JF_PORT}/Items?IncludeItemTypes=Series&Recursive=true&Fields=Path" \ + || echo "") + + echo "$series_json" | jq -c '.Items[]? | {Id, Path}' 2>/dev/null | while read -r row; do + sid=$(echo "$row" | jq -r '.Id // empty') + path=$(echo "$row" | jq -r '.Path // empty') + [ -n "$sid" ] || continue + jf_refresh_dir_has_video "$path" || continue + + eps=$(jf_refresh_curl \ + "http://${JF_HOST}:${JF_PORT}/Shows/${sid}/Episodes" || echo "") + if [ "$(echo "$eps" | jq -r '.TotalRecordCount // 0' 2>/dev/null || echo 0)" != "0" ]; then + continue + fi + + echo "Jellyfin refresh: healing empty series ${sid} (${path})" + jf_refresh_curl -o /dev/null -X POST \ + "http://${JF_HOST}:${JF_PORT}/Items/${sid}/Refresh?Recursive=true&MetadataRefreshMode=FullRefresh&ImageRefreshMode=Default&ReplaceAllMetadata=false" \ + || true + done +} + +if [ "${1:-}" = "--heal-only" ]; then + if [ -z "$JF_API_KEY" ]; then + echo "Jellyfin refresh: JELLYFIN_API_KEY not set" >&2 + exit 1 + fi + if mkdir "$JF_HEAL_LOCK_DIR" 2>/dev/null; then + echo $$ >"$JF_HEAL_LOCK_DIR/pid" + trap 'rm -rf "$JF_HEAL_LOCK_DIR"' EXIT + jf_refresh_heal_after_scan + else + oldpid=$(cat "$JF_HEAL_LOCK_DIR/pid" 2>/dev/null || echo "") + if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then + echo "Jellyfin refresh: heal already running (pid ${oldpid})" + exit 0 + fi + rm -rf "$JF_HEAL_LOCK_DIR" + mkdir "$JF_HEAL_LOCK_DIR" 2>/dev/null || exit 0 + echo $$ >"$JF_HEAL_LOCK_DIR/pid" + trap 'rm -rf "$JF_HEAL_LOCK_DIR"' EXIT + jf_refresh_heal_after_scan + fi + exit 0 +fi + +if jf_refresh_is_test_event; then echo "Jellyfin refresh: test event received, skipping" exit 0 fi @@ -25,14 +138,38 @@ if [ -z "$JF_API_KEY" ]; then exit 1 fi -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST \ - -H "X-Emby-Token: ${JF_API_KEY}" \ - "http://${JF_HOST}:${JF_PORT}/Library/Refresh" 2>/dev/null) +TASKS_JSON=$(jf_refresh_curl "http://${JF_HOST}:${JF_PORT}/ScheduledTasks" || echo "") +SCAN_STATE=$(jf_refresh_scan_state "$TASKS_JSON") -if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then - echo "Jellyfin refresh: triggered successfully (HTTP ${HTTP_CODE})" +if jf_refresh_scan_busy "$SCAN_STATE"; then + echo "Jellyfin refresh: scan already ${SCAN_STATE}, not cancelling" else - echo "Jellyfin refresh: failed (HTTP ${HTTP_CODE})" >&2 - exit 1 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 \ + -X POST \ + -H "X-Emby-Token: ${JF_API_KEY}" \ + "http://${JF_HOST}:${JF_PORT}/Library/Refresh" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "Jellyfin refresh: triggered successfully (HTTP ${HTTP_CODE})" + else + echo "Jellyfin refresh: failed (HTTP ${HTTP_CODE})" >&2 + exit 1 + fi fi + +# Sonarr/Radarr wait on this process; heal in the background so we do not +# block their notification pipeline. nohup survives the parent exiting. +SCRIPT_PATH="$0" +case "$SCRIPT_PATH" in + /*) ;; + *) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;; +esac +nohup env \ + JELLYFIN_HOST="$JF_HOST" \ + JELLYFIN_PORT="$JF_PORT" \ + JELLYFIN_API_KEY="$JF_API_KEY" \ + JF_REFRESH_HEAL_POLL_SEC="$JF_REFRESH_HEAL_POLL_SEC" \ + JF_REFRESH_HEAL_MAX_POLLS="$JF_REFRESH_HEAL_MAX_POLLS" \ + JF_HEAL_LOCK_DIR="$JF_HEAL_LOCK_DIR" \ + "$SCRIPT_PATH" --heal-only >/tmp/assemblrr-jf-heal.log 2>&1 & +exit 0 diff --git a/tests/run.sh b/tests/run.sh index 6c8a7aa..94436c5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -41,6 +41,7 @@ run_unit() { "$SCRIPT_DIR/unit/test_core.sh" \ "$SCRIPT_DIR/unit/test_env_example.sh" \ "$SCRIPT_DIR/unit/test_fzf_tui.sh" \ + "$SCRIPT_DIR/unit/test_jellyfin_refresh.sh" \ "$SCRIPT_DIR/unit/test_media_purge_match.sh" \ "$SCRIPT_DIR/unit/test_media_purge_watch.sh" \ "$SCRIPT_DIR/unit/test_prepare_install_dirs.sh" \ diff --git a/tests/unit/test_jellyfin_refresh.sh b/tests/unit/test_jellyfin_refresh.sh new file mode 100755 index 0000000..ffee8dd --- /dev/null +++ b/tests/unit/test_jellyfin_refresh.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# jellyfin-refresh helpers + *arr notification payload flags +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" + +JF_REFRESH_SOURCE_ONLY=1 +# shellcheck disable=SC1091 +source "$REPO_ROOT/scripts/jellyfin-refresh.sh" + +test_suite "jf_refresh_scan_state" +assert_eq "Running" \ + "$(jf_refresh_scan_state '[{"Key":"RefreshLibrary","State":"Running"}]')" \ + "running scan" +assert_eq "Idle" \ + "$(jf_refresh_scan_state '[{"Key":"RefreshLibrary","State":"Idle"}]')" \ + "idle scan" +assert_eq "Cancelling" \ + "$(jf_refresh_scan_state '[{"Key":"RefreshLibrary","State":"Cancelling"}]')" \ + "cancelling scan" +assert_eq "Idle" \ + "$(jf_refresh_scan_state '[]')" \ + "missing scan task is idle" +assert_eq "Idle" \ + "$(jf_refresh_scan_state '')" \ + "empty JSON is idle" + +test_suite "jf_refresh_scan_busy" +assert_true "running is busy" "jf_refresh_scan_busy Running" +assert_true "cancelling is busy" "jf_refresh_scan_busy Cancelling" +assert_false "idle is not busy" "jf_refresh_scan_busy Idle" +assert_false "empty is not busy" "jf_refresh_scan_busy ''" + +test_suite "jf_refresh_is_test_event" +radarr_eventtype="" +sonarr_eventtype="" +assert_false "no event" "jf_refresh_is_test_event" +radarr_eventtype="Test" +assert_true "radarr test" "jf_refresh_is_test_event" +radarr_eventtype="" +sonarr_eventtype="Download" +assert_false "sonarr download" "jf_refresh_is_test_event" +sonarr_eventtype="Test" +assert_true "sonarr test" "jf_refresh_is_test_event" +sonarr_eventtype="" + +test_suite "jf_refresh_dir_has_video" +_jf_tmp=$(mktemp -d) +assert_false "missing dir" "jf_refresh_dir_has_video /no/such/loki" +assert_false "empty dir" "jf_refresh_dir_has_video $_jf_tmp" +mkdir -p "$_jf_tmp/Season 1" +: >"$_jf_tmp/Season 1/notes.txt" +assert_false "only txt" "jf_refresh_dir_has_video $_jf_tmp" +: >"$_jf_tmp/Season 1/Show - S01E01.mkv" +assert_true "has mkv" "jf_refresh_dir_has_video $_jf_tmp" +rm -rf "$_jf_tmp" + +test_suite "jf_refresh_empty_series_ids" +assert_eq "aa11" \ + "$(jf_refresh_empty_series_ids '{"Items":[{"Id":"aa11","Path":"/data/media/tv/Loki"}]}')" \ + "extracts series id" + +test_suite "jellyfin refresh notif payload" +_sonarr_schema='[{"implementation":"CustomScript","fields":[{"name":"path","value":""}],"onDownload":false,"onUpgrade":false,"onImportComplete":false}]' +_radarr_schema='[{"implementation":"CustomScript","fields":[{"name":"path","value":""}],"onDownload":false,"onUpgrade":false}]' +_notif_jq=' + [.[] | select(.implementation == "CustomScript")][0] | + .fields = [.fields[] | if .name == "path" then .value = "/scripts/jellyfin-refresh.sh" else . end] | + .onUpgrade = true | .name = "Jellyfin Refresh" | .tags = [] | + if has("onImportComplete") then + .onDownload = false | .onImportComplete = true + else + .onDownload = true + end +' +_sonarr=$(echo "$_sonarr_schema" | jq "$_notif_jq") +_radarr=$(echo "$_radarr_schema" | jq "$_notif_jq") +assert_eq "false" "$(echo "$_sonarr" | jq -r .onDownload)" "sonarr onDownload off" +assert_eq "true" "$(echo "$_sonarr" | jq -r .onImportComplete)" "sonarr onImportComplete on" +assert_eq "true" "$(echo "$_sonarr" | jq -r .onUpgrade)" "sonarr onUpgrade on" +assert_eq "true" "$(echo "$_radarr" | jq -r .onDownload)" "radarr onDownload on" +assert_eq "null" "$(echo "$_radarr" | jq -r .onImportComplete)" "radarr has no onImportComplete" + +# Keep the filter above in sync with lib/jellyfin.sh +assert_contains "$(cat "$REPO_ROOT/lib/jellyfin.sh")" 'has("onImportComplete")' \ + "jellyfin.sh uses onImportComplete when the *arr schema has it" +assert_contains "$(cat "$REPO_ROOT/lib/jellyfin.sh")" '.onImportComplete = true' \ + "jellyfin.sh enables onImportComplete for Sonarr" + +test_summary