mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-10-19 01:11:09 +00:00
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:
parent
710540fc40
commit
e11ac87d7a
4 changed files with 269 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
92
tests/unit/test_jellyfin_refresh.sh
Executable file
92
tests/unit/test_jellyfin_refresh.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue