assemblrr/scripts/jellyfin-refresh.sh
soulis-1256 e11ac87d7a
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.
2026-08-14 11:45:10 +03:00

175 lines
6 KiB
Bash
Executable file

#!/bin/sh
# 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
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
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
if [ -z "$JF_API_KEY" ]; then
echo "Jellyfin refresh: JELLYFIN_API_KEY not set" >&2
exit 1
fi
TASKS_JSON=$(jf_refresh_curl "http://${JF_HOST}:${JF_PORT}/ScheduledTasks" || echo "")
SCAN_STATE=$(jf_refresh_scan_state "$TASKS_JSON")
if jf_refresh_scan_busy "$SCAN_STATE"; then
echo "Jellyfin refresh: scan already ${SCAN_STATE}, not cancelling"
else
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