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.
This commit is contained in:
soulis-1256 2026-08-14 11:45:10 +03:00
parent 710540fc40
commit e11ac87d7a
No known key found for this signature in database
4 changed files with 269 additions and 29 deletions

View file

@ -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

View file

@ -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

View file

@ -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" \

View file

@ -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