assemblrr/lib/jellyfin.sh
soulis-1256 a758a59aa5
feat(bazarr): multi-language subtitles and OpenSubtitles edit flow
Pick extra subtitle languages in setup and config edit (Jellyfin preferred
first, Bazarr downloads all). Config edit OpenSubtitles goes straight to
username/password, ignores leftover fzf Enter, and cancel leaves secrets.
2026-08-23 23:23:23 +03:00

591 lines
27 KiB
Bash

#!/bin/bash
# assemblrr Jellyfin configuration — sourced by config.sh
# Provides: configure_jellyfin, configure_jellyfin_xml_fallback,
# configure_jellyfin_libraries, configure_jellyfin_notifications
# Requires: lib/core.sh (logging), lib/api.sh (api_get/post helpers),
# config.sh (_cfg_log_info, log_step, log_step_fail, AUTH_USERNAME, etc.)
set -euo pipefail
# --- Jellyfin configuration ---
configure_jellyfin() {
local jellyfin_port=8096
local max_wait=120
local wait_time=0
# Step 1: Wait for Jellyfin to be responsive
echo >&2
while [ $wait_time -lt $max_wait ]; do
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "000")
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
break
fi
wait_inline "Waiting for Jellyfin to start" "$wait_time"
sleep 3
wait_time=$((wait_time + 3))
done
echo >&2
if [ $wait_time -ge $max_wait ]; then
log_step_fail "Jellyfin: not responsive after ${max_wait}s"
return 1
fi
# Step 2: Check if startup wizard is still pending
local public_info
public_info=$(curl -sf --connect-timeout 5 \
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
if [ -n "$public_info" ]; then
local wizard_complete
wizard_complete=$(echo "$public_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
if [ "$wizard_complete" = "True" ] || [ "$wizard_complete" = "true" ]; then
log_step "Jellyfin: startup wizard already completed"
echo "Configuring Jellyfin libraries" >&2
local lib_rc=0
configure_jellyfin_libraries || lib_rc=$?
echo >&2
return "$lib_rc"
fi
fi
# Step 3: Wait for the default startup user to be initialized
# Jellyfin creates a placeholder user on first boot; /Startup/User POST
# will return 500 if we hit it before the default user exists in the DB
_cfg_log_info "Jellyfin: attempting startup wizard bypass via API..."
echo >&2
local first_user_wait=0
while [ $first_user_wait -lt 60 ]; do
local first_user_resp
first_user_resp=$(curl -sf --connect-timeout 3 \
"http://${API_HOST}:${jellyfin_port}/Startup/FirstUser" 2>/dev/null || echo "")
if [ -n "$first_user_resp" ]; then
local first_user_name
first_user_name=$(echo "$first_user_resp" | jq -r '.Name // ""' 2>/dev/null || echo "")
if [ -n "$first_user_name" ]; then
break
fi
fi
wait_inline "Waiting for Jellyfin startup user to initialize" "$first_user_wait"
sleep 3
first_user_wait=$((first_user_wait + 3))
done
echo >&2
if [ $first_user_wait -ge 60 ]; then
log_step_fail "Jellyfin: startup user not initialized after 60s"
configure_jellyfin_xml_fallback
return $?
fi
# Step 4: Set startup configuration (locale)
local locale_code
locale_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "Content-Type: application/json" \
-d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' \
"http://${API_HOST}:${jellyfin_port}/Startup/Configuration" 2>/dev/null || echo "000")
if [ "$locale_code" -ge 200 ] && [ "$locale_code" -lt 300 ]; then
log_step "Jellyfin: set startup locale (en-US)"
else
log_step_fail "Jellyfin: failed to set startup locale (HTTP $locale_code)"
fi
# Step 5: Set admin user credentials via Startup API
local escaped_username escaped_password
escaped_username=$(printf '%s' "$AUTH_USERNAME" | jq -Rs . 2>/dev/null)
escaped_password=$(printf '%s' "$AUTH_PASSWORD" | jq -Rs . 2>/dev/null)
# Fallback if jq fails
escaped_username=${escaped_username:-"\"$AUTH_USERNAME\""}
escaped_password=${escaped_password:-"\"$AUTH_PASSWORD\""}
local user_payload="{\"Name\":${escaped_username},\"Password\":${escaped_password},\"EnableAutoLogin\":false}"
local user_code
user_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "Content-Type: application/json" \
-d "$user_payload" \
"http://${API_HOST}:${jellyfin_port}/Startup/User" 2>/dev/null || echo "000")
if [ "$user_code" -ge 200 ] && [ "$user_code" -lt 300 ]; then
log_step "Jellyfin: set admin user credentials ($AUTH_USERNAME)"
else
log_step_fail "Jellyfin: failed to set admin user via API (HTTP $user_code)"
configure_jellyfin_xml_fallback
return $?
fi
# Step 6: Complete the startup wizard
local complete_code
complete_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "Content-Type: application/json" \
"http://${API_HOST}:${jellyfin_port}/Startup/Complete" 2>/dev/null || echo "000")
if [ "$complete_code" -ge 200 ] && [ "$complete_code" -lt 300 ]; then
log_step "Jellyfin: startup wizard completed via API"
else
log_step_fail "Jellyfin: failed to complete startup wizard (HTTP $complete_code)"
configure_jellyfin_xml_fallback
return $?
fi
# Step 7: Verify wizard is complete
sleep 3
local verify_info
verify_info=$(curl -sf --connect-timeout 5 \
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
local wizard_status
wizard_status=$(echo "$verify_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
if [ "$wizard_status" = "True" ] || [ "$wizard_status" = "true" ]; then
log_step "Jellyfin: startup wizard bypass verified"
else
log_step_fail "Jellyfin: wizard completion could not be verified via API"
configure_jellyfin_xml_fallback
return $?
fi
# Step 8: Add media libraries
echo "Configuring Jellyfin libraries" >&2
local lib_rc=0
configure_jellyfin_libraries || lib_rc=$?
echo >&2
return "$lib_rc"
}
configure_jellyfin_xml_fallback() {
local system_xml="$INSTALL_DIR/config/jellyfin/system.xml"
local max_wait=60
local wait_time=0
# Wait for system.xml to appear
echo >&2
while [ $wait_time -lt $max_wait ]; do
if [ -f "$system_xml" ]; then
break
fi
wait_inline "Waiting for Jellyfin system.xml" "$wait_time"
sleep 3
wait_time=$((wait_time + 3))
done
echo >&2
if [ ! -f "$system_xml" ]; then
log_step_fail "Jellyfin: system.xml not found after ${max_wait}s"
return 1
fi
# Mark wizard as complete
if grep -q '<IsStartupWizardCompleted>false</IsStartupWizardCompleted>' "$system_xml" 2>/dev/null; then
sed -i 's/<IsStartupWizardCompleted>false<\/IsStartupWizardCompleted>/<IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>/g' "$system_xml"
log_step "Jellyfin: marked startup wizard as complete in system.xml"
elif grep -q '<IsStartupWizardCompleted>' "$system_xml" 2>/dev/null; then
sed -i 's/<IsStartupWizardCompleted>[^<]*<\/IsStartupWizardCompleted>/<IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>/g' "$system_xml"
log_step "Jellyfin: updated IsStartupWizardCompleted in system.xml"
else
# Tag doesn't exist — add it before the closing tag
sed -i 's/<\/ServerConfiguration>/ <IsStartupWizardCompleted>true<\/IsStartupWizardCompleted>\n<\/ServerConfiguration>/' "$system_xml"
log_step "Jellyfin: added IsStartupWizardCompleted to system.xml"
fi
# Restart Jellyfin to apply changes
docker restart jellyfin 2>/dev/null || true
log_step "Jellyfin: restarted to apply system.xml changes"
# Wait for Jellyfin to come back online
echo >&2
local back_wait=0
while [ $back_wait -lt 60 ]; do
if curl -sf --connect-timeout 3 "http://${API_HOST}:8096/health" >/dev/null 2>&1; then
break
fi
wait_inline "Waiting for Jellyfin to come back online" "$back_wait"
sleep 3
back_wait=$((back_wait + 3))
done
echo >&2
}
configure_jellyfin_libraries() {
local jellyfin_port=8096
# Authenticate to get access token
local auth_response
local auth_payload
auth_payload=$(jq -nc --arg u "$AUTH_USERNAME" --arg p "$AUTH_PASSWORD" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H 'X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="setup-script", DeviceId="assemblrr-setup"' \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -z "$auth_response" ]; then
log_step_fail "Jellyfin: failed to authenticate for library setup"
return 1
fi
local access_token
access_token=$(echo "$auth_response" | jq -r '.AccessToken // ""' 2>/dev/null || echo "")
if [ -z "$access_token" ]; then
log_step_fail "Jellyfin: failed to get access token"
return 1
fi
local user_id
user_id=$(echo "$auth_response" | jq -r '.User.Id // ""' 2>/dev/null || echo "")
# Define media libraries to add
local -a lib_names=("Movies" "TV Shows")
local -a lib_types=("movies" "tvshows")
local -a lib_paths=("/data/media/movies" "/data/media/tv")
# Only add Music if the host media/music folder exists
if [ -n "${MEDIA_DIRECTORY:-}" ] && [ -d "${MEDIA_DIRECTORY}/media/music" ]; then
lib_names+=("Music")
lib_types+=("music")
lib_paths+=("/data/media/music")
fi
# Fetch existing libraries to avoid duplicates
local existing_libs
existing_libs=$(curl -sf --connect-timeout 5 \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders" 2>/dev/null || echo "[]")
# Remove duplicate libraries (same path, different name — e.g. "Movies2")
local dupe_ids
dupe_ids=$(echo "$existing_libs" | jq -r '.[] | . as $lib | .Locations[]? as $loc | {"/data/media/movies": "Movies", "/data/media/tv": "TV Shows", "/data/media/music": "Music"}[$loc] as $expected | select($expected and $lib.Name != $expected) | "\(.ItemId)|\(.Name)"' 2>/dev/null || echo "")
if [ -n "$dupe_ids" ]; then
while IFS='|' read -r dupe_id dupe_name; do
curl -sf -o /dev/null -w "" -X DELETE \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Items/${dupe_id}" 2>/dev/null
log_step "Jellyfin: removed duplicate library '${dupe_name}'"
done <<< "$dupe_ids"
# Re-fetch after deletions
existing_libs=$(curl -sf --connect-timeout 5 \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders" 2>/dev/null || echo "[]")
fi
local critical_errors=0
for i in "${!lib_names[@]}"; do
local lib_name="${lib_names[$i]}"
local lib_type="${lib_types[$i]}"
local lib_path="${lib_paths[$i]}"
# Check if a library with this name and path already exists
local lib_exists
lib_exists=$(echo "$existing_libs" | jq --arg ln "$lib_name" --arg lp "$lib_path" -r 'if [.[] | select(.Name == $ln and (.Locations // [] | index($lp)))][0] then "yes" else "no" end' 2>/dev/null || echo "no")
if [ "$lib_exists" = "yes" ]; then
log_step "Jellyfin: ${lib_name} library already exists (${lib_path})"
continue
fi
# URL-encode the name and path for query params
local enc_name enc_path
enc_name=$(jq -rn --arg n "$lib_name" '$n | @uri' 2>/dev/null || echo "${lib_name}")
enc_path=$(jq -rn --arg p "$lib_path" '($p | @uri) | gsub("%2F"; "/")' 2>/dev/null || echo "${lib_path}")
local add_code
add_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Library/VirtualFolders?name=${enc_name}&collectionType=${lib_type}&paths=${enc_path}&refreshProgress=0" \
2>/dev/null || echo "000")
if [ "$add_code" -ge 200 ] && [ "$add_code" -lt 300 ]; then
log_step "Jellyfin: added ${lib_name} library (${lib_path})"
else
log_step_fail "Jellyfin: failed to add ${lib_name} library (HTTP $add_code)"
if [ "$lib_type" = "movies" ] || [ "$lib_type" = "tvshows" ]; then
critical_errors=$((critical_errors + 1))
fi
fi
done
# Set subtitle preference: always show subtitles in user's preferred language
# Jellyfin's SubtitleLanguagePreference uses ISO 639-2/T three-letter codes
# (e.g. "eng", "fra", "deu") — NOT locale codes like "en-US" and NOT two-letter
# codes like "en". The UI dropdown matches these codes to display language names.
# We must also GET the full user config first and POST it back modified,
# otherwise Jellyfin wipes all other user settings.
if [ -n "$user_id" ]; then
local sub_lang="${SUBTITLE_LANGUAGE:-en}"
sub_lang="${sub_lang%%,*}"
sub_lang=${sub_lang// /}
# Convert ISO 639-1 (two-letter) to ISO 639-2/T (three-letter) code
# Jellyfin uses ISO 639-2/T (e.g., "eng" not "en"), so we must convert.
# If Jellyfin starts accepting 639-1 directly, this mapping can be removed.
local jf_lang
jf_lang=$(jq -rn --arg sl "$sub_lang" '($sl | ascii_downcase) | if length == 3 then . else ({"aa":"aar","ab":"abk","af":"afr","ak":"aka","am":"amh","an":"arg","ar":"ara","as":"asm","av":"ava","ay":"aym","az":"aze","ba":"bak","be":"bel","bg":"bul","bh":"bih","bi":"bis","bm":"bam","bn":"ben","bo":"bod","br":"bre","bs":"bos","ca":"cat","ce":"che","ch":"cha","co":"cos","cr":"cre","cs":"ces","cu":"chu","cv":"chv","cy":"cym","da":"dan","de":"deu","dv":"div","dz":"dzo","ee":"ewe","el":"ell","en":"eng","eo":"epo","es":"spa","et":"est","eu":"eus","fa":"fas","ff":"ful","fi":"fin","fj":"fij","fo":"fao","fr":"fra","fy":"fry","ga":"gle","gd":"gla","gl":"glg","gn":"grn","gu":"guj","gv":"glv","ha":"hau","he":"heb","hi":"hin","ho":"hmo","hr":"hrv","ht":"hat","hu":"hun","hy":"hye","hz":"her","ia":"ina","id":"ind","ie":"ile","ig":"ibo","ii":"iii","ik":"ipk","io":"ido","is":"isl","it":"ita","iu":"iku","ja":"jpn","jv":"jav","ka":"kat","kg":"kon","ki":"kik","kj":"kua","kk":"kaz","kl":"kal","km":"khm","kn":"kan","ko":"kor","kr":"kau","ks":"kas","ku":"kur","kv":"kom","kw":"cor","ky":"kir","la":"lat","lb":"ltz","lg":"lug","li":"lim","ln":"lin","lo":"lao","lt":"lit","lu":"lub","lv":"lav","mg":"mlg","mh":"mah","mi":"mri","mk":"mkd","ml":"mal","mn":"mon","mr":"mar","ms":"msa","mt":"mlt","my":"mya","na":"nau","nb":"nob","nd":"nde","ne":"nep","ng":"ndo","nl":"nld","nn":"nno","no":"nor","nr":"nbl","nv":"nav","ny":"nya","oc":"oci","oj":"oji","om":"orm","or":"ori","os":"oss","pa":"pan","pi":"pli","pl":"pol","ps":"pus","pt":"por","qu":"que","rm":"roh","rn":"run","ro":"ron","ru":"rus","rw":"kin","sa":"san","sc":"srd","sd":"snd","se":"sme","sg":"sag","si":"sin","sk":"slk","sl":"slv","sm":"smo","sn":"sna","so":"som","sq":"sqi","sr":"srp","ss":"ssw","st":"sot","su":"sun","sv":"swe","sw":"swa","ta":"tam","te":"tel","tg":"tgk","th":"tha","ti":"tir","tk":"tuk","tl":"tgl","tn":"tsn","to":"ton","tr":"tur","ts":"tso","tt":"tat","tw":"twi","ty":"tah","ug":"uig","uk":"ukr","ur":"urd","uz":"uzb","ve":"ven","vi":"vie","vo":"vol","wa":"wln","wo":"wol","xh":"xho","yi":"yid","yo":"yor","za":"zha","zh":"zho","zu":"zul"}[.] // .) end' 2>/dev/null || echo "$sub_lang")
# GET current config, patch subtitle fields, POST back
# Note: GET /Users/{id}/Configuration returns 405; must GET /Users/{id} instead
# and extract the Configuration object, then POST it to /Users/{id}/Configuration
local user_obj
user_obj=$(curl -s -H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null)
local patched_cfg
patched_cfg=$(echo "$user_obj" | jq --arg lang "$jf_lang" '.Configuration.SubtitleMode = "Always" | .Configuration.SubtitleLanguagePreference = $lang | .Configuration' 2>/dev/null)
local sub_pref_code
sub_pref_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
-d "$patched_cfg" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Configuration" \
2>/dev/null || echo "000")
if [ "$sub_pref_code" -ge 200 ] && [ "$sub_pref_code" -lt 300 ]; then
log_step "Jellyfin: set subtitle preference (Always, ${jf_lang})"
else
log_step_fail "Jellyfin: failed to set subtitle preference (HTTP $sub_pref_code)"
fi
fi
# Create an API key for Radarr/Sonarr notification connections
# POST /Auth/Keys?app=assemblrr returns 204 with no body, so we must
# create it first, then GET /Auth/Keys to retrieve the actual key value.
mkdir -p "$INSTALL_DIR/secrets"
local key_create_code
key_create_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 -X POST \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Auth/Keys?app=assemblrr" 2>/dev/null || echo "000")
if [ "$key_create_code" = "204" ] || [ "$key_create_code" = "200" ]; then
local keys_response
keys_response=$(curl -s --connect-timeout 5 \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Auth/Keys" 2>/dev/null || echo "")
local jf_api_key
jf_api_key=$(echo "$keys_response" | jq -r '.Items[] | select(.AppName == "assemblrr") | .AccessToken // ""' 2>/dev/null | head -1 || echo "")
if [ -n "$jf_api_key" ]; then
echo -n "$jf_api_key" > "$INSTALL_DIR/secrets/jellyfin_api_key.txt"
chmod 600 "$INSTALL_DIR/secrets/jellyfin_api_key.txt"
log_step "Jellyfin: created API key for Radarr/Sonarr connections"
else
log_step_fail "Jellyfin: API key created but failed to retrieve value"
fi
else
log_step_fail "Jellyfin: failed to create API key (HTTP $key_create_code)"
fi
[ "$critical_errors" -eq 0 ]
}
# Change an existing Jellyfin/Emby user's password (and name if it changed).
# Usage: jellyfin_change_credentials <old_user> <old_pass> <new_user> <new_pass> [port] [label]
jellyfin_change_credentials() {
local old_user="$1"
local old_pass="$2"
local new_user="$3"
local new_pass="$4"
local jellyfin_port="${5:-8096}"
local label="${6:-Jellyfin}"
local auth_header='X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="config-edit", DeviceId="assemblrr-auth"'
if [ -z "$new_user" ] || [ -z "$new_pass" ]; then
log_step_fail "${label}: new credentials missing"
return 1
fi
local public_info wizard_complete
public_info=$(curl -sf --connect-timeout 5 \
"http://${API_HOST}:${jellyfin_port}/System/Info/Public" 2>/dev/null || echo "")
wizard_complete=$(echo "$public_info" | jq -r '.StartupWizardCompleted // ""' 2>/dev/null || echo "")
if [ "$wizard_complete" != "True" ] && [ "$wizard_complete" != "true" ]; then
if [ "$label" = "Jellyfin" ] && type configure_jellyfin >/dev/null 2>&1; then
configure_jellyfin
return $?
fi
log_step_fail "${label}: startup wizard is still open — finish first boot, then retry"
return 1
fi
local auth_payload auth_response
auth_payload=$(jq -nc --arg u "$old_user" --arg p "$old_pass" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H "$auth_header" \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -z "$auth_response" ]; then
auth_payload=$(jq -nc --arg u "$new_user" --arg p "$new_pass" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H "$auth_header" \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -n "$auth_response" ]; then
local already_name
already_name=$(echo "$auth_response" | jq -r '.User.Name // ""' 2>/dev/null || echo "")
if [ "$already_name" = "$new_user" ]; then
log_step "${label}: already using the new password"
return 0
fi
# New password already works — only the display name still needs updating.
old_pass="$new_pass"
else
log_step_fail "${label}: could not log in with the old password"
return 1
fi
fi
local access_token user_id
access_token=$(echo "$auth_response" | jq -r '.AccessToken // ""' 2>/dev/null || echo "")
user_id=$(echo "$auth_response" | jq -r '.User.Id // ""' 2>/dev/null || echo "")
if [ -z "$access_token" ] || [ -z "$user_id" ]; then
log_step_fail "${label}: login response missing token"
return 1
fi
if [ "$old_pass" != "$new_pass" ]; then
local pw_payload pw_code
pw_payload=$(jq -nc --arg cur "$old_pass" --arg new "$new_pass" '{CurrentPw:$cur,NewPw:$new}')
pw_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
-d "$pw_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Password" 2>/dev/null || echo "000")
if [ "$pw_code" -lt 200 ] || [ "$pw_code" -ge 300 ]; then
log_step_fail "${label}: failed to set password (HTTP $pw_code)"
return 1
fi
fi
if [ "$old_user" != "$new_user" ]; then
local enc_name name_code
enc_name=$(jq -rn --arg n "$new_user" '$n | @uri' 2>/dev/null || echo "$new_user")
name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}/Name?name=${enc_name}" \
2>/dev/null || echo "000")
if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then
local user_obj patched
user_obj=$(curl -s --connect-timeout 8 \
-H "X-Emby-Token: ${access_token}" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "")
patched=$(echo "$user_obj" | jq --arg n "$new_user" '.Name = $n' 2>/dev/null || echo "")
name_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 -X POST \
-H "Content-Type: application/json" \
-H "X-Emby-Token: ${access_token}" \
-d "$patched" \
"http://${API_HOST}:${jellyfin_port}/Users/${user_id}" 2>/dev/null || echo "000")
fi
if [ "$name_code" -lt 200 ] || [ "$name_code" -ge 300 ]; then
log_step_fail "${label}: password updated, username change failed (HTTP $name_code)"
return 1
fi
fi
log_step "${label}: set login (${new_user})"
return 0
}
# --- Jellyfin notification connections (Radarr/Sonarr → Jellyfin) ---
configure_jellyfin_notifications() {
local jf_api_key
jf_api_key=$(cat "$INSTALL_DIR/secrets/jellyfin_api_key.txt" 2>/dev/null || echo "")
if [ -z "$jf_api_key" ]; then
log_step_fail "Jellyfin notifications: API key not found — skipping"
return 1
fi
# NOTE: The MediaBrowser/Emby notification type calls /Library/Media/Updated which
# only refreshes EXISTING items in Jellyfin's DB — it cannot discover NEW files.
# 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
errors=$((errors + 1))
fi
if ! add_jellyfin_refresh_notif "Sonarr" "8989" "$SONARR_API_KEY"; then
errors=$((errors + 1))
fi
[ "$errors" -eq 0 ]
}
# Add a CustomScript notification to an *arr app that triggers Jellyfin library refresh.
# Usage: add_jellyfin_refresh_notif <app_name> <port> <api_key>
add_jellyfin_refresh_notif() {
local app_name="$1"
local port="$2"
local apikey="$3"
if [ -z "$apikey" ]; then
return 0
fi
local notifications existing_id schema payload result
notifications=$(api_get "$port" "/api/v3/notification" "$apikey")
existing_id=$(echo "$notifications" | jq -r '.[] | select(.implementation == "CustomScript" and .name == "Jellyfin Refresh") | .id // ""' 2>/dev/null | head -1 || echo "")
schema=$(api_get "$port" "/api/v3/notification/schema" "$apikey")
# 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] |
.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
log_step_fail "${app_name}: CustomScript notification schema not found"
return 1
fi
# Ensure the mounted script is executable. Radarr/Sonarr try to start the
# process when saving a CustomScript (even with forceSave) and reject the
# notification with "Permission denied" if the bit is missing.
if [ -f "$INSTALL_DIR/scripts/jellyfin-refresh.sh" ]; then
chmod +x "$INSTALL_DIR/scripts/jellyfin-refresh.sh" 2>/dev/null || true
fi
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 (scan on import complete)"
return 0
fi
local err_msg
err_msg=$(echo "$result" | jq -r 'if type == "array" then .[0].errorMessage // empty else .errorMessage // empty end' 2>/dev/null || echo "")
if [ -n "$err_msg" ]; then
log_step_fail "${app_name}: failed to add Jellyfin Refresh script (${err_msg})"
else
log_step_fail "${app_name}: failed to add Jellyfin Refresh script"
fi
return 1
}