Compare commits

..
8 Commits
Author SHA1 Message Date
Gmer4Lfe fbe6a54e41 Fix three image cleanup gaps: weekly restart prune, mode-aware rebuild error, monthly orphan sweep
docker_weekly_restart.sh was missing the trailing dangling prune that daily_restart has.
docker_update.sh rebuild failure message always named docker_daily_restart.sh regardless of mode.
docker_prune_images.sh --all added to monthly — the only scheduled path that removes tagged orphan images.
2026-06-25 21:37:20 -04:00
Gmer4Lfe d058cf15c9 Add favorite sync to play_state_sync — union semantics, music first
Favorites on any server propagate to all others; never unmarks.
Covers MusicArtist, MusicAlbum, Movie, Series (Audio tracks future).
Provider map extended to include FAV_TYPES alongside SYNC_TYPES,
with Series/Episode TVDB IDs namespaced to avoid collisions.
2026-06-22 23:42:39 -04:00
Gmer4Lfe 4de10a7d01 Drop Audio from PLAY_SYNC_TYPES — music library too large for play state sync 2026-06-22 23:32:50 -04:00
Gmer4Lfe 01e4f97361 Hold DNS cutover until play_state_sync succeeds on handback
Retries up to PLAY_SYNC_HANDBACK_RETRIES times (default 5, 60s apart)
before giving up — one successful run catches all state regardless of
outage length, so users land on current watch state after DNS flips.
2026-06-22 23:22:06 -04:00
Gmer4Lfe 284896fbd9 Update TIER1_WRITEBACK_DELAY comment — no longer Emby-specific 2026-06-22 23:16:36 -04:00
Gmer4Lfe c4d4d8160d Replace Emby dirty-sync workaround with play_state_sync in handback
Emby now runs continuously on both hosts — play_state_sync via API
handles watch state reconciliation, making the 30-min dirty rsync
redundant. Handback runs play_state_sync --wait before DNS cutover
so users land on current state after a failover event.

- Emby removed from FALLBACK_HOST1_TIER1 (always running)
- Emby dirty sync removed from HOST1_CRITICAL_SYNC_SHARES
- Emby writeback removed from FALLBACK_HOST1_WRITEBACK_TIER1
- play_state_sync gains --wait flag (uses lock wait mode for handback)
- Tier 1 emby-fallback special case removed from handback writeback loop
- play_state_sync --wait added as Step 7 in handback, before DNS cutover
2026-06-22 23:14:36 -04:00
Gmer4Lfe 438de76655 Fix PROV_LOOKUP build: paginate by type to avoid Jellyfin mixed-query sort corruption
Single mixed Movie,Episode,Audio query caused Jellyfin to reorder items unpredictably,
pushing most episodes past the page limit and leaving PROV_LOOKUP empty — every
Emby→Jellyfin push silently failed with 'item not found'. Also removes the
ExcludeLocationTypes=Virtual param which caused the same corruption in JF.
2026-06-22 13:54:19 -04:00
Gmer4Lfe 86b8e894e5 Bypass PHP chain in array_stop_jobs — call array_stopping.sh directly via run_job.sh
PHP scheduler load at shutdown time can fail silently (errors suppressed); a failed PHP call meant the stop script never ran without any visible indication.
2026-06-22 12:45:31 -04:00
8 changed files with 282 additions and 44 deletions
+14
View File
@@ -453,6 +453,7 @@
# State file on /boot/config — survives reboots (interval gate independent of uptime gate). # State file on /boot/config — survives reboots (interval gate independent of uptime gate).
MONTHLY_MAINTENANCE_SCRIPTS=( MONTHLY_MAINTENANCE_SCRIPTS=(
"Docker_Essentials/docker_update.sh --remainder" # update all containers not in daily/weekly managed lists "Docker_Essentials/docker_update.sh --remainder" # update all containers not in daily/weekly managed lists
"Tools/docker_prune_images.sh --all" # remove stopped containers then all unused images (tagged orphans)
"Tools/zfs_pool_scrub.sh" # ZFS pool integrity scrub — all pools, skip ZFS_REPORT_IGNORE_POOLS "Tools/zfs_pool_scrub.sh" # ZFS pool integrity scrub — all pools, skip ZFS_REPORT_IGNORE_POOLS
"Tools/smart_long_test.sh" # SMART extended self-test — all drives, sequential "Tools/smart_long_test.sh" # SMART extended self-test — all drives, sequential
) )
@@ -691,6 +692,19 @@
FALLBACK_PARTNERSHIP_SUSPEND_AFTER=120 # minutes without active partnership before suspending FALLBACK_PARTNERSHIP_SUSPEND_AFTER=120 # minutes without active partnership before suspending
# 0 = suspend immediately when partnership goes inactive # 0 = suspend immediately when partnership goes inactive
# ━━━ Play State Sync ━━━
PLAY_SYNC_ENABLED=true
PLAY_SYNC_REMOTE=true # sync across hosts via Tailscale
PLAY_SYNC_DAYS=90 # days back to check for played items (0 = all time)
PLAY_SYNC_TYPES="Movie,Episode" # Audio excluded — music library too large; favorites handled separately
PLAY_SYNC_FAV_TYPES="MusicArtist,MusicAlbum,Movie,Series" # union sync — never unmarks; Audio tracks future
# ━━━ Play State Sync — Handback ━━━
# play_state_sync runs before DNS cutover so users land on current watch state.
# Retried until success — DNS is held until sync completes or retries are exhausted.
PLAY_SYNC_HANDBACK_RETRIES=5 # attempts before giving up and proceeding to DNS cutover
PLAY_SYNC_HANDBACK_RETRY_DELAY=60 # seconds between retry attempts
# ━━━ Failover Test ━━━ # ━━━ Failover Test ━━━
# Controlled simulation of a failover event — run manually via fallback_test.sh. # Controlled simulation of a failover event — run manually via fallback_test.sh.
FALLBACK_TEST_BLOCK_WAIT=150 # seconds to wait after blocking connectivity FALLBACK_TEST_BLOCK_WAIT=150 # seconds to wait after blocking connectivity
+1 -1
View File
@@ -281,7 +281,7 @@
HOSTN_TIER4_DELAY=1440 # 24 hours HOSTN_TIER4_DELAY=1440 # 24 hours
# ━━━ Rsync Writeback ━━━ # ━━━ Rsync Writeback ━━━
HOSTN_TIER1_WRITEBACK_DELAY=60 # skip Emby writeback if outage under 1hr HOSTN_TIER1_WRITEBACK_DELAY=60 # minimum outage minutes before Tier 1 writeback runs
FALLBACK_HOSTN_WRITEBACK_TIER1=( FALLBACK_HOSTN_WRITEBACK_TIER1=(
# "/mnt/user/Media_Server/Emby" # "/mnt/user/Media_Server/Emby"
+13
View File
@@ -686,6 +686,19 @@
FALLBACK_PARTNERSHIP_SUSPEND_AFTER=120 # minutes without active partnership before suspending FALLBACK_PARTNERSHIP_SUSPEND_AFTER=120 # minutes without active partnership before suspending
# 0 = suspend immediately when partnership goes inactive # 0 = suspend immediately when partnership goes inactive
# ━━━ Play State Sync ━━━
PLAY_SYNC_ENABLED=true
PLAY_SYNC_REMOTE=true # sync across hosts via Tailscale
PLAY_SYNC_DAYS=90 # days back to check for played items (0 = all time)
PLAY_SYNC_TYPES="Movie,Episode" # Audio excluded — music library too large; favorites handled separately
PLAY_SYNC_FAV_TYPES="MusicArtist,MusicAlbum,Movie,Series" # union sync — never unmarks; Audio tracks future
# ━━━ Play State Sync — Handback ━━━
# play_state_sync runs before DNS cutover so users land on current watch state.
# Retried until success — DNS is held until sync completes or retries are exhausted.
PLAY_SYNC_HANDBACK_RETRIES=5 # attempts before giving up and proceeding to DNS cutover
PLAY_SYNC_HANDBACK_RETRY_DELAY=60 # seconds between retry attempts
# ━━━ Failover Test ━━━ # ━━━ Failover Test ━━━
# Controlled simulation of a failover event — run manually via fallback_test.sh. # Controlled simulation of a failover event — run manually via fallback_test.sh.
FALLBACK_TEST_BLOCK_WAIT=150 # seconds to wait after blocking connectivity FALLBACK_TEST_BLOCK_WAIT=150 # seconds to wait after blocking connectivity
+3 -1
View File
@@ -397,7 +397,9 @@ if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE $container rebuilt ✅" echo "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container") REBUILT+=("$container")
else else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh" _fallback_msg=$([[ "$WEEKLY_MODE" == true ]] && echo "docker_weekly_restart.sh" || ([[ "$REMAINDER_MODE" == true ]] && echo "no follow-up restart" || echo "docker_daily_restart.sh"))
error "Failed to rebuild $container$_fallback_msg"
unset _fallback_msg
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning" notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container") REBUILD_FAILED+=("$container")
fi fi
@@ -268,6 +268,21 @@ done
END=$(date +%s) END=$(date +%s)
# ==============================================================================================
# ━━━ Prune Old Images ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Pruning Dangling Images — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would prune dangling images"
PRUNED_SUMMARY="(dry run)"
else
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
# ============================================================================================== # ==============================================================================================
# ━━━ Summary ━━━ # ━━━ Summary ━━━
# ============================================================================================== # ==============================================================================================
@@ -280,6 +295,7 @@ if [[ ${#RESTARTED[@]} -gt 0 ]]; then
fi fi
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]} (were stopped)" [[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_NOT_RUNNING Skipped: ${SKIPPED[*]} (were stopped)"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}" [[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no changes made" echo "$ICON_WARN Status: DRY RUN — no changes made"
+28 -6
View File
@@ -772,11 +772,7 @@ run_handback() {
[[ -z "$job" ]] && continue [[ -z "$job" ]] && continue
log "Syncing: $job" log "Syncing: $job"
if [[ "$DRY_RUN" == false ]]; then if [[ "$DRY_RUN" == false ]]; then
if [[ "$(basename "$job")" == "Emby" ]]; then
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" --profile=emby-fallback
else
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job"
fi
else else
warn "DRY RUN — would rsync: $job" warn "DRY RUN — would rsync: $job"
fi fi
@@ -797,7 +793,33 @@ run_handback() {
[[ "$is_ddns" == false ]] && remote_start "$container" [[ "$is_ddns" == false ]] && remote_start "$container"
done done
# ── Step 7: DNS Cutover — final step ──────────────────────────────────────────────────── # ── Step 7: Play State Sync — DNS held until sync succeeds or retries exhausted ──────────
# Retried so users land on current watch state — after a 3hr outage one successful
# run catches everything regardless of how many cron cycles were missed.
echo ""
echo "━━━ $ICON_SYNC Play State Sync ━━━"
local _sync_ok=false
local _retry_max="${PLAY_SYNC_HANDBACK_RETRIES:-5}"
local _retry_delay="${PLAY_SYNC_HANDBACK_RETRY_DELAY:-60}"
if [[ "$DRY_RUN" == false ]]; then
for _attempt in $(seq 1 "$_retry_max"); do
if bash "$SCRIPT_DIR/../Media/play_state_sync.sh" --wait; then
_sync_ok=true
log "Play state synced ✅ (attempt ${_attempt}/${_retry_max})"
break
fi
if [[ "$_attempt" -lt "$_retry_max" ]]; then
warn "Play state sync failed (attempt ${_attempt}/${_retry_max}) — retrying in ${_retry_delay}s"
sleep "$_retry_delay"
fi
done
[[ "$_sync_ok" == false ]] && \
warn "Play state sync failed after ${_retry_max} attempts — proceeding to DNS cutover"
else
warn "DRY RUN — would retry play_state_sync --wait up to ${_retry_max} times before DNS cutover"
fi
# ── Step 8: DNS Cutover — final step ────────────────────────────────────────────────────
# DNS cuts over ONLY after Tier 1 containers confirmed up # DNS cuts over ONLY after Tier 1 containers confirmed up
echo "" echo ""
echo "━━━ $ICON_NET DNS Cutover ━━━" echo "━━━ $ICON_NET DNS Cutover ━━━"
@@ -807,7 +829,7 @@ run_handback() {
done done
warn "$ICON_NET Remote DDNS started — DNS now points at $REMOTE_SERVER_NAME" warn "$ICON_NET Remote DDNS started — DNS now points at $REMOTE_SERVER_NAME"
# ── Step 8: Return to NORMAL ───────────────────────────────────────────────────────────── # ── Step 9: Return to NORMAL ─────────────────────────────────────────────────────────────
echo "" echo ""
state_set state "NORMAL" state_set state "NORMAL"
state_set fallback_start "0" state_set fallback_start "0"
+190 -12
View File
@@ -73,11 +73,18 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh" source "$SCRIPT_DIR/../load_config.sh"
# ── Handle --full before parse_args ─────────────────────────────────────────── # ── Handle --full and --wait before parse_args ────────────────────────────────
FULL_SYNC=false FULL_SYNC=false
LOCK_MODE="strict"
_FILTERED=() _FILTERED=()
for _a in "$@"; do for _a in "$@"; do
[[ "$_a" == "--full" ]] && FULL_SYNC=true || _FILTERED+=("$_a") if [[ "$_a" == "--full" ]]; then
FULL_SYNC=true
elif [[ "$_a" == "--wait" ]]; then
LOCK_MODE="wait"
else
_FILTERED+=("$_a")
fi
done done
parse_args "${_FILTERED[@]}" parse_args "${_FILTERED[@]}"
@@ -87,13 +94,14 @@ parse_args "${_FILTERED[@]}"
[[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0 [[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0
SYNC_DAYS="${PLAY_SYNC_DAYS:-90}" SYNC_DAYS="${PLAY_SYNC_DAYS:-90}"
SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode,Audio}" SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode}"
FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}"
[[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0 [[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0
log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} remote=${PLAY_SYNC_REMOTE:-true}" log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true}"
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; } command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
acquire_lock acquire_lock "$LOCK_MODE"
# ── Build server list across ALL hosts ──────────────────────────────────────── # ── Build server list across ALL hosts ────────────────────────────────────────
# All HOST*_TRANSCODE_SERVERS arrays are loaded into env by load_config.sh. # All HOST*_TRANSCODE_SERVERS arrays are loaded into env by load_config.sh.
@@ -276,24 +284,62 @@ done
# per-item API call and is faster overall. # per-item API call and is faster overall.
declare -A PROV_LOOKUP # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → item_id declare -A PROV_LOOKUP # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → item_id
# Jellyfin's mixed-type query (Movie,Episode,Audio) changes sort order unpredictably,
# pushing items to positions far beyond the first pages. Query each type separately so
# each list sorts within its own type and items appear where expected.
_PROV_PAGE=2000
# Combine play state types + fav types for the map build (deduplicated)
declare -A _prov_seen_dedup
_prov_types=()
IFS=',' read -ra _prov_combined <<< "${SYNC_TYPES},${FAV_TYPES}"
for _t in "${_prov_combined[@]}"; do
[[ -n "${_prov_seen_dedup[$_t]:-}" ]] && continue
_prov_seen_dedup[$_t]=1; _prov_types+=("$_t")
done
unset _prov_seen_dedup _prov_combined
for _psi in $(seq 0 $(( _srv_count - 1 ))); do for _psi in $(seq 0 $(( _srv_count - 1 ))); do
[[ -z "${SRV_URL[$_psi]}" ]] && continue [[ -z "${SRV_URL[$_psi]}" ]] && continue
log "Building provider ID map for ${SRV_NAME[$_psi]}..." log "Building provider ID map for ${SRV_NAME[$_psi]}..."
_raw_prov=$(_api_get "${SRV_URL[$_psi]}" "${SRV_KEY[$_psi]}" \ for _prov_type in "${_prov_types[@]}"; do
"Items?Recursive=true&IncludeItemTypes=${SYNC_TYPES}&Fields=ProviderIds&ExcludeLocationTypes=Virtual" 2>/dev/null) _prov_start=0
[[ -z "$_raw_prov" ]] && continue _prov_total=-1
while IFS=$'\t' read -r _pid _ptvdb _pimdb _ptmdb _pmbtrack; do while true; do
[[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" _page=$(curl -sf --max-time 60 \
-H "X-Emby-Token: ${SRV_KEY[$_psi]}" \
"${SRV_URL[$_psi]%/}/Items?Recursive=true&IncludeItemTypes=${_prov_type}&Fields=ProviderIds&Limit=${_PROV_PAGE}&StartIndex=${_prov_start}" 2>/dev/null)
[[ -z "$_page" ]] && break
[[ "$_prov_total" -lt 0 ]] && _prov_total=$(echo "$_page" | jq '.TotalRecordCount // 0' 2>/dev/null || echo 0)
_prov_count=$(echo "$_page" | jq '.Items | length' 2>/dev/null || echo 0)
[[ "$_prov_count" -eq 0 ]] && break
while IFS=$'\t' read -r _pid _ptype _ptvdb _pimdb _ptmdb _pmbtrack _pmbartist _pmbalbum; do
[[ "$_pimdb" != "null" && -n "$_pimdb" ]] && PROV_LOOKUP["${_psi}|imdb.${_pimdb}"]="$_pid" [[ "$_pimdb" != "null" && -n "$_pimdb" ]] && PROV_LOOKUP["${_psi}|imdb.${_pimdb}"]="$_pid"
[[ "$_ptmdb" != "null" && -n "$_ptmdb" ]] && PROV_LOOKUP["${_psi}|tmdb.${_ptmdb}"]="$_pid" [[ "$_ptmdb" != "null" && -n "$_ptmdb" ]] && PROV_LOOKUP["${_psi}|tmdb.${_ptmdb}"]="$_pid"
[[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && PROV_LOOKUP["${_psi}|mb.${_pmbtrack}"]="$_pid" [[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && PROV_LOOKUP["${_psi}|mb.${_pmbtrack}"]="$_pid"
done < <(echo "$_raw_prov" | jq -r '.Items[] | [ [[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && PROV_LOOKUP["${_psi}|mb.artist.${_pmbartist}"]="$_pid"
[[ "$_pmbalbum" != "null" && -n "$_pmbalbum" ]] && PROV_LOOKUP["${_psi}|mb.album.${_pmbalbum}"]="$_pid"
# tvdb: namespace by type so Series and Episode IDs don't collide
if [[ "$_ptvdb" != "null" && -n "$_ptvdb" ]]; then
case "$_ptype" in
Series) PROV_LOOKUP["${_psi}|tvdb.series.${_ptvdb}"]="$_pid" ;;
Episode) PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;;
*) PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;;
esac
fi
done < <(echo "$_page" | jq -r '.Items[] | [
.Id, .Id,
.Type,
(.ProviderIds.Tvdb // "null"), (.ProviderIds.Tvdb // "null"),
(.ProviderIds.Imdb // "null"), (.ProviderIds.Imdb // "null"),
(.ProviderIds.Tmdb // "null"), (.ProviderIds.Tmdb // "null"),
(.ProviderIds.MusicBrainzTrackId // "null") (.ProviderIds.MusicBrainzTrackId // "null"),
(.ProviderIds.MusicBrainzArtistId // "null"),
(.ProviderIds.MusicBrainzAlbumId // "null")
] | @tsv' 2>/dev/null) ] | @tsv' 2>/dev/null)
_prov_start=$(( _prov_start + _prov_count ))
[[ "$_prov_total" -gt 0 && "$_prov_start" -ge "$_prov_total" ]] && break
done
done
done done
# ── Step 3: Sync per matched user ──────────────────────────────────────────── # ── Step 3: Sync per matched user ────────────────────────────────────────────
@@ -559,6 +605,136 @@ for lname in "${!USER_MAP[@]}"; do
unset ITEM_MAP U_IDX U_UID unset ITEM_MAP U_IDX U_UID
done done
# ==============================================================================================
# ━━━ Favorite Sync ━━━
# ==============================================================================================
# Union semantics — if favorited on any server, sync to all others. Never unmarks.
# Covers MusicArtist, MusicAlbum, Movie, Series (configured via PLAY_SYNC_FAV_TYPES).
FAV_TOTAL_SYNCED=0
FAV_TOTAL_SKIPPED=0
FAV_TOTAL_ERRORS=0
IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES"
for lname in "${!USER_MAP[@]}"; do
read -ra _pairs <<< "${USER_MAP[$lname]}"
[[ "${#_pairs[@]}" -lt 2 ]] && continue
declare -A UF_IDX UF_UID
for _pair in "${_pairs[@]}"; do
IFS=':' read -r _si _ui <<< "$_pair"
UF_IDX["$_si"]="$_si"
UF_UID["$_si"]="$_ui"
done
declare -A FAV_MAP # provider_key → space-separated "si:iid" pairs
for _si in "${!UF_IDX[@]}"; do
_uid="${UF_UID[$_si]}"
for _ftype in "${_fav_type_list[@]}"; do
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds")
[[ -z "$_resp" ]] && continue
_fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0)
[[ "$_fcount" -eq 0 ]] && continue
log " ${SRV_NAME[$_si]}$_fcount ${_ftype} favorite(s) for $lname"
while IFS=$'\t' read -r _iid _itype _pimdb _ptmdb _ptvdb _pmbartist _pmbalbum _pmbtrack; do
_pkey=""
case "$_itype" in
Movie)
[[ "$_pimdb" != "null" && -n "$_pimdb" ]] && _pkey="imdb:${_pimdb}"
[[ -z "$_pkey" && "$_ptmdb" != "null" && -n "$_ptmdb" ]] && _pkey="tmdb:movie:${_ptmdb}"
;;
Series)
[[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && _pkey="tvdb:series:${_ptvdb}"
;;
MusicArtist)
[[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && _pkey="mb:artist:${_pmbartist}"
;;
MusicAlbum)
[[ "$_pmbalbum" != "null" && -n "$_pmbalbum" ]] && _pkey="mb:album:${_pmbalbum}"
;;
Audio)
[[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && _pkey="mb:track:${_pmbtrack}"
;;
esac
[[ -z "$_pkey" ]] && continue
if [[ -n "${FAV_MAP[$_pkey]:-}" ]]; then
FAV_MAP[$_pkey]+=" ${_si}:${_iid}"
else
FAV_MAP[$_pkey]="${_si}:${_iid}"
fi
done < <(echo "$_resp" | jq -r '.Items[] | [
.Id, .Type,
(.ProviderIds.Imdb // "null"),
(.ProviderIds.Tmdb // "null"),
(.ProviderIds.Tvdb // "null"),
(.ProviderIds.MusicBrainzArtistId // "null"),
(.ProviderIds.MusicBrainzAlbumId // "null"),
(.ProviderIds.MusicBrainzTrackId // "null")
] | @tsv' 2>/dev/null)
done
done
for _pkey in "${!FAV_MAP[@]}"; do
declare -A _fhave _fiid
for _entry in ${FAV_MAP[$_pkey]}; do
IFS=':' read -r _si _iid <<< "$_entry"
_fhave[$_si]=true
_fiid[$_si]="$_iid"
done
for _si in "${!UF_IDX[@]}"; do
[[ "${_fhave[$_si]:-false}" == "true" ]] && (( FAV_TOTAL_SKIPPED++ )) && continue
_uid="${UF_UID[$_si]}"
# Look up item ID on target server via PROV_LOOKUP
_iid=""
_ptype="${_pkey%%:*}"
case "$_ptype" in
imdb) _iid="${PROV_LOOKUP[${_si}|imdb.${_pkey#imdb:}]:-}" ;;
tmdb) _iid="${PROV_LOOKUP[${_si}|tmdb.${_pkey#tmdb:movie:}]:-}" ;;
tvdb) _iid="${PROV_LOOKUP[${_si}|tvdb.series.${_pkey#tvdb:series:}]:-}" ;;
mb)
_mbsub="${_pkey#mb:}"
case "${_mbsub%%:*}" in
artist) _iid="${PROV_LOOKUP[${_si}|mb.artist.${_mbsub#artist:}]:-}" ;;
album) _iid="${PROV_LOOKUP[${_si}|mb.album.${_mbsub#album:}]:-}" ;;
track) _iid="${PROV_LOOKUP[${_si}|mb.${_mbsub#track:}]:-}" ;;
esac
;;
esac
if [[ -z "$_iid" ]]; then
log " SKIP FAV $_pkey${SRV_NAME[$_si]} not in library"
continue
fi
log " FAV $_pkey${SRV_NAME[$_si]} ($lname)"
if [[ "$DRY_RUN" == false ]]; then
_http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/FavoriteItems/${_iid}")
if [[ "$_http" == "200" || "$_http" == "201" ]]; then
success " ✓ FAV $_pkey${SRV_NAME[$_si]}"
(( FAV_TOTAL_SYNCED++ ))
else
warn " ✗ FAV $_pkey${SRV_NAME[$_si]} failed (HTTP ${_http:-err})"
(( FAV_TOTAL_ERRORS++ ))
fi
else
echo " DRY RUN: would favorite $_pkey${SRV_NAME[$_si]} user=$lname"
(( FAV_TOTAL_SYNCED++ ))
fi
done
unset _fhave _fiid
done
unset FAV_MAP UF_IDX UF_UID
done
# ============================================================================================== # ==============================================================================================
# ━━━ Summary ━━━ # ━━━ Summary ━━━
# ============================================================================================== # ==============================================================================================
@@ -569,6 +745,8 @@ echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_DONE Synced: $TOTAL_SYNCED" echo "$ICON_DONE Synced: $TOTAL_SYNCED"
[[ "$TOTAL_SKIPPED" -gt 0 ]] && echo "$ICON_RUNNING Skipped: $TOTAL_SKIPPED (already current)" [[ "$TOTAL_SKIPPED" -gt 0 ]] && echo "$ICON_RUNNING Skipped: $TOTAL_SKIPPED (already current)"
[[ "$TOTAL_ERRORS" -gt 0 ]] && echo "$ICON_ERROR Errors: $TOTAL_ERRORS" [[ "$TOTAL_ERRORS" -gt 0 ]] && echo "$ICON_ERROR Errors: $TOTAL_ERRORS"
echo ""
echo " Favorites — synced: $FAV_TOTAL_SYNCED skipped: $FAV_TOTAL_SKIPPED errors: $FAV_TOTAL_ERRORS"
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes written" warn "DRY RUN — no changes written"
@@ -1,16 +1,9 @@
#!/bin/bash #!/bin/bash
# Varaverk: fire all jobs with cron=array_stop (foreground — blocks until all done). # Varaverk: run array_stopping.sh on array stop (foreground — blocks until done).
php -r " # Direct bash call to avoid PHP/scheduler chain failing silently at shutdown time.
require_once '/usr/local/emhttp/plugins/varaverk/include/scheduler.php'; SCRIPTS_DIR="/boot/config/plugins/varaverk"
\$s = vv_schedule_load(); cfg="$SCRIPTS_DIR/varaverk.cfg"
\$runner = '/usr/local/emhttp/plugins/varaverk/run_job.sh'; [[ -f "$cfg" ]] && _sd=$(grep -oP '(?<=^SCRIPTS_DIR=")[^"]+' "$cfg" 2>/dev/null) && [[ -n "$_sd" ]] && SCRIPTS_DIR="$_sd"
foreach (\$s as \$id => \$e) { runner="$SCRIPTS_DIR/Plugin/unraid/run_job.sh"
if (strncmp(\$id, '__', 2) === 0) continue; script="$SCRIPTS_DIR/Orchestrators/array_stopping.sh"
if ((\$e['cron'] ?? '') !== 'array_stop') continue; [[ -f "$script" ]] && bash "$runner" "Orchestrators/array_stopping.sh" "$script"
if (empty(\$e['enabled'])) continue;
\$script = SCRIPTS_DIR . '/' . \$id;
if (!file_exists(\$script)) continue;
\$flags = !empty(\$e['log_enabled']) ? ' --log' : '';
passthru('bash ' . escapeshellarg(\$runner) . ' ' . escapeshellarg(\$id) . ' ' . escapeshellarg(\$script) . \$flags);
}
" 2>/dev/null