Files
Varaverk/Media/play_state_sync.sh
T
Gmer4Lfe f42ecc8464 Add docker actions, arr profile enforcer, monitor caching, and web file symlink
Web files now served via symlink to the git repo so git pull changes survive
reboots without rebuilding the txz. Also includes: docker pull/rebuild/restart
with live log streaming, arr_profile_enforcer for Sonarr/Radarr quality
profiles, monitor page cache fix (background writer now in cron), and
ARR_KIDS/SONARR/RADARR profile name vars in master.conf.
2026-06-19 11:09:40 -04:00

570 lines
25 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ================================= Play State Sync ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Syncs watched/played state and resume positions across all configured Emby
# and Jellyfin servers. Newest timestamp wins — no data is ever lost.
#
# Users are matched by name (case-insensitive). If a user exists on some servers
# but not others, those servers are skipped for that user — no errors, no partial
# syncs from unrelated accounts.
#
# Items are matched by external provider IDs:
# Movies → IMDb ID, then TMDB ID
# Episodes → TVDB ID + season + episode number
# Audio → MusicBrainz Track ID
#
# ==============================================================================================
# SYNC LOGIC
# ==============================================================================================
#
# For each matched item across ≥2 servers:
# 1. Compare LastPlayedDate across all servers that have a play record.
# 2. The server with the newest LastPlayedDate is authoritative.
# 3. Push that server's state (Played, PlayCount, LastPlayedDate,
# PlaybackPositionTicks) to every other server.
# 4. Servers with no record for that item also receive the state.
#
# Resume positions (partial plays, not marked Played):
# Synced by comparing PlaybackPositionTicks when LastPlayedDate is absent.
# The higher tick count wins.
#
# ==============================================================================================
# CONFIGURATION (host*.conf, aliased by detect_hosts)
# ==============================================================================================
#
# HOST*_TRANSCODE_SERVERS "Name|URL|APIKey|type" entries per host (emby/jellyfin)
# All hosts are discovered automatically — no extra config needed.
# Remote host URLs have localhost rewritten to their Tailscale IP.
#
# master.conf
#
# PLAY_SYNC_ENABLED Master toggle (default: true)
# PLAY_SYNC_REMOTE Sync across all hosts via Tailscale (default: true)
# false = local servers only (this host's Emby + Jellyfin)
# PLAY_SYNC_DAYS How many days back to check for played items (default: 90)
# Use 0 to sync all played items (slow on large libraries).
# PLAY_SYNC_TYPES Comma-separated item types to sync (default: Movie,Episode,Audio)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# play_state_sync.sh
# Sync all matched users across all configured servers.
#
# play_state_sync.sh --dry-run
# Show what would be synced without writing any state.
#
# play_state_sync.sh --status
# Show configured servers, reachability, and user counts.
#
# play_state_sync.sh --full
# Ignore PLAY_SYNC_DAYS — sync all played items (may be slow).
#
# play_state_sync.sh --log
# Verbose output — show each item comparison.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Handle --full before parse_args ───────────────────────────────────────────
FULL_SYNC=false
_FILTERED=()
for _a in "$@"; do
[[ "$_a" == "--full" ]] && FULL_SYNC=true || _FILTERED+=("$_a")
done
parse_args "${_FILTERED[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "${PLAY_SYNC_ENABLED:-true}" != "true" ]] && echo "Play state sync disabled" && exit 0
SYNC_DAYS="${PLAY_SYNC_DAYS:-90}"
SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode,Audio}"
[[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0
log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} remote=${PLAY_SYNC_REMOTE:-true}"
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
acquire_lock
# ── Build server list across ALL hosts ────────────────────────────────────────
# All HOST*_TRANSCODE_SERVERS arrays are loaded into env by load_config.sh.
# For remote hosts, localhost in the URL is rewritten to their Tailscale IP.
declare -a SRV_NAME SRV_URL SRV_KEY SRV_TYPE
_srv_count=0
_my_hostname=$(hostname -s)
_add_server() {
local name="$1" url="$2" key="$3" type="$4"
[[ -z "$url" || -z "$key" ]] && return
[[ "$key" == "YOUR_API_KEY"* || "$key" == "placeholder"* ]] && return
SRV_NAME[$_srv_count]="$name"
SRV_URL[$_srv_count]="$url"
SRV_KEY[$_srv_count]="$key"
SRV_TYPE[$_srv_count]="$type"
(( _srv_count++ ))
}
for _varname in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
_num="${_varname//[^0-9]/}"
_host_hostname="${!_varname}"
[[ -z "$_host_hostname" ]] && continue
_is_me=false
[[ "${_host_hostname,,}" == "${_my_hostname,,}" ]] && _is_me=true
# Skip remote hosts when PLAY_SYNC_REMOTE=false
if [[ "$_is_me" == false && "${PLAY_SYNC_REMOTE:-true}" != "true" ]]; then
log "$_host_hostname — remote sync disabled (PLAY_SYNC_REMOTE=false), skipping"
continue
fi
# Resolve Tailscale IP for remote hosts
_ts_ip=""
if [[ "$_is_me" == false ]]; then
_ts_ip=$(resolve_tailscale_ip "$_host_hostname")
if [[ -z "$_ts_ip" ]]; then
log "$_host_hostname — Tailscale IP not found, skipping"
continue
fi
fi
# Load this host's TRANSCODE_SERVERS array
_srv_arr_name="HOST${_num}_TRANSCODE_SERVERS"
eval "_host_entries=(\"\${${_srv_arr_name}[@]}\")"
[[ "${#_host_entries[@]}" -eq 0 ]] && continue
for _entry in "${_host_entries[@]}"; do
IFS='|' read -r _name _url _key _type <<< "$_entry"
[[ "$_type" == "emby" || "$_type" == "jellyfin" ]] || continue
# For remote hosts rewrite localhost/127.0.0.1 → Tailscale IP
if [[ "$_is_me" == false ]]; then
_url="${_url//localhost/$_ts_ip}"
_url="${_url//127.0.0.1/$_ts_ip}"
fi
_add_server "${_name} (${_host_hostname})" "$_url" "$_key" "$_type"
done
done
if [[ "$_srv_count" -lt 2 ]]; then
error "Need at least 2 media servers configured — found $_srv_count"
exit 1
fi
# ==============================================================================================
# ━━━ API Helpers ━━━
# ==============================================================================================
_api_get() {
local url="$1" key="$2" endpoint="$3"
curl -sf --max-time 30 \
-H "X-Emby-Token: $key" \
"${url%/}/${endpoint}" 2>/dev/null
}
_api_post() {
local url="$1" key="$2" endpoint="$3" data="${4:-}"
if [[ -n "$data" ]]; then
curl -sf --max-time 30 -s -o /dev/null -w "%{http_code}" -X POST \
-H "X-Emby-Token: $key" \
-H "Content-Type: application/json" \
-d "$data" \
"${url%/}/${endpoint}" 2>/dev/null
else
curl -sf --max-time 30 -s -o /dev/null -w "%{http_code}" -X POST \
-H "X-Emby-Token: $key" \
"${url%/}/${endpoint}" 2>/dev/null
fi
}
# ISO 8601 date → unix seconds (portable, no date -d on BusyBox)
_iso_to_epoch() {
local dt="$1"
[[ -z "$dt" || "$dt" == "null" ]] && echo 0 && return
# Strip fractional seconds and Z, convert to seconds
dt="${dt%.*}" # remove .NNNNNNN
dt="${dt%Z}" # remove trailing Z
dt="${dt/T/ }" # T → space
date -u -d "$dt UTC" +%s 2>/dev/null || echo 0
}
# Ticks → seconds (1 tick = 100ns, 10_000_000 ticks = 1s)
_ticks_to_sec() {
echo $(( ${1:-0} / 10000000 ))
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━"
echo "$ICON_GEAR Remote sync: ${PLAY_SYNC_REMOTE:-true}"
echo "$ICON_GEAR Sync days: ${SYNC_DAYS:-all}"
echo "$ICON_GEAR Item types: $SYNC_TYPES"
echo ""
for i in $(seq 0 $(( _srv_count - 1 ))); do
echo "$ICON_HOST [${SRV_TYPE[$i]}] ${SRV_NAME[$i]} (${SRV_URL[$i]})"
_users=$(_api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users" 2>/dev/null | jq -r '.[].Name' 2>/dev/null | wc -l)
if [[ "$_users" -gt 0 ]]; then
echo " $ICON_DONE Reachable — $_users user(s)"
_api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users" 2>/dev/null \
| jq -r '.[].Name' 2>/dev/null | while read -r n; do echo " · $n"; done
else
echo " $ICON_ERROR Unreachable or no users"
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Main Sync ━━━
# ==============================================================================================
echo "━━━ $ICON_SYNC Play State Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no state will be written"
[[ "$FULL_SYNC" == true ]] && log "Full sync mode — ignoring PLAY_SYNC_DAYS"
START=$(date +%s)
TOTAL_SYNCED=0
TOTAL_SKIPPED=0
TOTAL_ERRORS=0
# ── Step 1: Fetch users from each server ─────────────────────────────────────
declare -A SRV_USERS # idx → JSON array string of users
log "Fetching users..."
for i in $(seq 0 $(( _srv_count - 1 ))); do
_resp=$(_api_get "${SRV_URL[$i]}" "${SRV_KEY[$i]}" "Users")
if [[ -z "$_resp" ]]; then
warn "${SRV_NAME[$i]} — unreachable, skipping"
SRV_USERS[$i]=""
continue
fi
SRV_USERS[$i]="$_resp"
_count=$(echo "$_resp" | jq 'length' 2>/dev/null || echo 0)
log "${SRV_NAME[$i]}$_count user(s)"
done
# ── Step 2: Build cross-server user map ──────────────────────────────────────
# lowercase_name → "server_idx:user_id server_idx:user_id ..."
declare -A USER_MAP
for i in $(seq 0 $(( _srv_count - 1 ))); do
[[ -z "${SRV_USERS[$i]}" ]] && continue
while IFS=$'\t' read -r uid uname; do
[[ -z "$uid" || -z "$uname" ]] && continue
lname="${uname,,}"
if [[ -n "${USER_MAP[$lname]}" ]]; then
USER_MAP[$lname]+=" ${i}:${uid}"
else
USER_MAP[$lname]="${i}:${uid}"
fi
done < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)
done
# ── Step 2.5: Pre-build provider ID → item ID lookup map ─────────────────────
# AnyProviderIdEquals is broken in Jellyfin 10.11+ (ignores the filter entirely).
# Pre-fetching all items' provider IDs once and using a local map avoids the broken
# per-item API call and is faster overall.
declare -A PROV_LOOKUP # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → item_id
for _psi in $(seq 0 $(( _srv_count - 1 ))); do
[[ -z "${SRV_URL[$_psi]}" ]] && continue
log "Building provider ID map for ${SRV_NAME[$_psi]}..."
_raw_prov=$(_api_get "${SRV_URL[$_psi]}" "${SRV_KEY[$_psi]}" \
"Items?Recursive=true&IncludeItemTypes=${SYNC_TYPES}&Fields=ProviderIds&ExcludeLocationTypes=Virtual" 2>/dev/null)
[[ -z "$_raw_prov" ]] && continue
while IFS=$'\t' read -r _pid _ptvdb _pimdb _ptmdb _pmbtrack; do
[[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid"
[[ "$_pimdb" != "null" && -n "$_pimdb" ]] && PROV_LOOKUP["${_psi}|imdb.${_pimdb}"]="$_pid"
[[ "$_ptmdb" != "null" && -n "$_ptmdb" ]] && PROV_LOOKUP["${_psi}|tmdb.${_ptmdb}"]="$_pid"
[[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && PROV_LOOKUP["${_psi}|mb.${_pmbtrack}"]="$_pid"
done < <(echo "$_raw_prov" | jq -r '.Items[] | [
.Id,
(.ProviderIds.Tvdb // "null"),
(.ProviderIds.Imdb // "null"),
(.ProviderIds.Tmdb // "null"),
(.ProviderIds.MusicBrainzTrackId // "null")
] | @tsv' 2>/dev/null)
done
# ── Step 3: Sync per matched user ────────────────────────────────────────────
_date_filter=""
if [[ "$SYNC_DAYS" -gt 0 ]]; then
_cutoff=$(date -u -d "$SYNC_DAYS days ago" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \
date -u -v "-${SYNC_DAYS}d" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)
[[ -n "$_cutoff" ]] && _date_filter="&MinDateLastSaved=$_cutoff"
fi
for lname in "${!USER_MAP[@]}"; do
read -ra _pairs <<< "${USER_MAP[$lname]}"
# Skip users only on one server
[[ "${#_pairs[@]}" -lt 2 ]] && log " $lname — only on 1 server, skipping" && continue
echo ""
echo "── User: $lname (${#_pairs[@]} server(s)) ──"
# Build per-server user context
declare -A U_IDX U_UID
for _pair in "${_pairs[@]}"; do
IFS=':' read -r _si _ui <<< "$_pair"
U_IDX["$_si"]="$_si"
U_UID["$_si"]="$_ui"
done
# ── Fetch played items from each server for this user ────────────────────
# Key: provider_id_string → sorted list of (epoch, srv_idx, item_id, play_count, ticks, played)
declare -A ITEM_MAP # provider_key → JSON per-server data
for _si in "${!U_IDX[@]}"; do
_uid="${U_UID[$_si]}"
# All played items — no limit, covers both date-stamped and batch-marked (null date) entries
_endpoint="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending"
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint")
if [[ -z "$_resp" ]]; then
warn " ${SRV_NAME[$_si]} — failed to fetch items for $lname"
continue
fi
# Resume positions (not yet marked played)
_endpoint2="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsResumable"
_resp2=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint2")
# Combine and deduplicate by Id
[[ -z "$_resp2" ]] && _resp2='{"Items":[]}'
_combined=$(printf '%s\n%s' "$_resp" "$_resp2" | jq -s \
'[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null)
_count=$(echo "$_combined" | jq 'length' 2>/dev/null || echo 0)
log " ${SRV_NAME[$_si]}$_count item(s) with state for $lname"
# Build item lookup by provider key
while IFS=$'\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount; do
# Build canonical provider key
_pkey=""
case "$itype" in
Movie)
[[ "$imdb" != "null" && -n "$imdb" ]] && _pkey="imdb:${imdb}"
[[ -z "$_pkey" && "$tmdb" != "null" && -n "$tmdb" ]] && _pkey="tmdb:movie:${tmdb}"
;;
Episode)
[[ "$tvdb" != "null" && -n "$tvdb" && "$season" != "null" && "$ep" != "null" ]] && \
_pkey="tvdb:ep:${tvdb}:s${season}e${ep}"
;;
Audio)
[[ "$mbtrack" != "null" && -n "$mbtrack" ]] && _pkey="mb:track:${mbtrack}"
;;
esac
[[ -z "$_pkey" ]] && continue
_epoch=$(_iso_to_epoch "$lplayed")
_entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}"
if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then
ITEM_MAP[$_pkey]+=$'\n'"$_entry"
else
ITEM_MAP[$_pkey]="$_entry"
fi
done < <(echo "$_combined" | jq -r '.[] | [
.Id,
.Type,
(.ParentIndexNumber // "null" | tostring),
(.IndexNumber // "null" | tostring),
(.ProviderIds.Imdb // "null"),
(.ProviderIds.Tmdb // "null"),
(.ProviderIds.Tvdb // "null"),
(.ProviderIds.MusicBrainzTrackId // "null"),
(.UserData.Played // false | tostring),
(.UserData.PlaybackPositionTicks // 0 | tostring),
(.UserData.LastPlayedDate // "null"),
(.UserData.PlayCount // 0 | tostring)
] | @tsv' 2>/dev/null)
done
# ── Compare and sync ─────────────────────────────────────────────────────
for _pkey in "${!ITEM_MAP[@]}"; do
# Collect all server entries for this item
declare -A E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX
_has_entries=false
while IFS='|' read -r _si _iid _played _pcount _ticks _epoch _lplayed; do
[[ -z "$_si" ]] && continue
E_SIDX[$_si]="$_si"
E_IID[$_si]="$_iid"
E_PLAYED[$_si]="$_played"
E_PCOUNT[$_si]="$_pcount"
E_TICKS[$_si]="$_ticks"
E_EPOCH[$_si]="$_epoch"
E_LPLAYED[$_si]="$_lplayed"
_has_entries=true
done <<< "${ITEM_MAP[$_pkey]}"
[[ "$_has_entries" == false ]] && continue
# Find the authoritative server: newest LastPlayedDate epoch
# Tie-break: higher PlayCount, then higher Ticks, then Played=true
# Init at -1 so servers with epoch=0 (batch-marks with null LastPlayedDate) can win
_auth_si=""
_auth_epoch=-1
_auth_pcount=-1
_auth_ticks=-1
_auth_pf="false"
for _si in "${!E_SIDX[@]}"; do
_e="${E_EPOCH[$_si]:-0}"
_pc="${E_PCOUNT[$_si]:-0}"
_tk="${E_TICKS[$_si]:-0}"
_pf="${E_PLAYED[$_si]:-false}"
if [[ "$_e" -gt "$_auth_epoch" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -gt "$_auth_pcount" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -eq "$_auth_pcount" && "$_tk" -gt "$_auth_ticks" ]] || \
[[ "$_e" -eq "$_auth_epoch" && "$_pc" -eq "$_auth_pcount" && "$_tk" -eq "$_auth_ticks" && "$_pf" == "true" && "$_auth_pf" != "true" ]]; then
_auth_si="$_si"
_auth_epoch="$_e"
_auth_pcount="$_pc"
_auth_ticks="$_tk"
_auth_pf="$_pf"
fi
done
[[ -z "$_auth_si" ]] && continue
_auth_played="${E_PLAYED[$_auth_si]}"
_auth_lplayed="${E_LPLAYED[$_auth_si]}"
_auth_pcount="${E_PCOUNT[$_auth_si]}"
_auth_ticks="${E_TICKS[$_auth_si]}"
# Push to servers with older state OR no state at all
for _si in "${!U_IDX[@]}"; do
[[ "$_si" == "$_auth_si" ]] && continue
_their_epoch="${E_EPOCH[$_si]:-0}"
_their_played="${E_PLAYED[$_si]:-false}"
# Skip if they already have the same/newer state
_skip=false
if [[ "$_auth_played" == "true" ]]; then
# Both servers already have this played — nothing to propagate regardless of dates.
# Date-based comparison caused a ping-pong: syncing without a DatePlayed param lets
# the target server stamp the current time, making it the new authority next cycle.
[[ "$_their_played" == "true" ]] && _skip=true
else
# Resume only: skip if target already has same or more ticks.
# Allow 5-second tolerance (50_000_000 ticks) — Emby may round tick values slightly
# differently on read, causing an exact-match check to miss and re-sync every cycle.
_tick_gap=$(( ${_auth_ticks:-0} - ${E_TICKS[$_si]:-0} ))
[[ "$_tick_gap" -le 50000000 && "${E_TICKS[$_si]:-0}" -gt 0 && "$_their_played" == "false" ]] && _skip=true
fi
if [[ "$_skip" == true ]]; then
log " SKIP $_pkey${SRV_NAME[$_si]} already up to date"
(( TOTAL_SKIPPED++ ))
continue
fi
_uid="${U_UID[$_si]}"
_iid="${E_IID[$_si]:-}" # might not exist on this server yet
# Find item ID on target server by provider key if not in our map
if [[ -z "$_iid" ]]; then
_ptype="${_pkey%%:*}"
case "$_ptype" in
imdb)
_pval="${_pkey#imdb:}"
_iid="${PROV_LOOKUP[${_si}|imdb.${_pval}]:-}"
;;
tmdb)
_pval="${_pkey#tmdb:movie:}"
_iid="${PROV_LOOKUP[${_si}|tmdb.${_pval}]:-}"
;;
tvdb)
# pkey format: tvdb:ep:{tvdb_id}:s{season}e{ep}
_tvdb_num="${_pkey#tvdb:ep:}"; _tvdb_num="${_tvdb_num%%:*}"
_iid="${PROV_LOOKUP[${_si}|tvdb.${_tvdb_num}]:-}"
;;
mb)
_pval="${_pkey#mb:track:}"
_iid="${PROV_LOOKUP[${_si}|mb.${_pval}]:-}"
;;
esac
[[ -z "$_iid" ]] && log " SKIP $_pkey${SRV_NAME[$_si]} item not found on server" && continue
fi
log " SYNC $_pkey${SRV_NAME[$_si]} (auth: ${SRV_NAME[$_auth_si]}, epoch: $_auth_epoch)"
if [[ "$DRY_RUN" == true ]]; then
echo " DRY RUN: would sync $_pkey${SRV_NAME[$_si]} user=$lname played=$_auth_played date=$_auth_lplayed"
(( TOTAL_SYNCED++ ))
continue
fi
# Write state to target server
if [[ "$_auth_played" == "true" ]]; then
# Mark as played with date
_date_param=""
if [[ "$_auth_lplayed" != "null" && -n "$_auth_lplayed" ]]; then
# PlayedItems expects DatePlayed in yyyyMMddHHmmss (no separators, no timezone)
_lp="${_auth_lplayed%.*}"
_lp="${_lp%Z}"
_date_param="?DatePlayed=${_lp//[-T:]/}"
fi
_http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/PlayedItems/${_iid}${_date_param}")
if [[ "$_http" == "200" || "$_http" == "201" ]]; then
success " ✓ $_pkey${SRV_NAME[$_si]} marked played"
(( TOTAL_SYNCED++ ))
else
warn " ✗ $_pkey${SRV_NAME[$_si]} failed (HTTP ${_http:-err})"
(( TOTAL_ERRORS++ ))
fi
else
# Sync resume position only
_ticks_int=$(( ${_auth_ticks:-0} ))
if [[ "$_ticks_int" -gt 0 ]]; then
_payload="{\"PlaybackPositionTicks\":${_ticks_int}}"
_http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
"Users/${_uid}/Items/${_iid}/UserData" "$_payload")
if [[ "$_http" == "200" || "$_http" == "204" ]]; then
success " ✓ $_pkey${SRV_NAME[$_si]} resume synced ($(_ticks_to_sec "$_ticks_int")s)"
(( TOTAL_SYNCED++ ))
else
warn " ✗ $_pkey${SRV_NAME[$_si]} resume sync failed (HTTP ${_http:-err})"
(( TOTAL_ERRORS++ ))
fi
fi
fi
done
unset E_EPOCH E_PLAYED E_PCOUNT E_TICKS E_IID E_LPLAYED E_SIDX
done
unset ITEM_MAP U_IDX U_UID
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_DONE Synced: $TOTAL_SYNCED"
[[ "$TOTAL_SKIPPED" -gt 0 ]] && echo "$ICON_RUNNING Skipped: $TOTAL_SKIPPED (already current)"
[[ "$TOTAL_ERRORS" -gt 0 ]] && echo "$ICON_ERROR Errors: $TOTAL_ERRORS"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes written"
elif [[ "$TOTAL_ERRORS" -eq 0 ]]; then
success "Done ✅"
else
warn "Done with $TOTAL_ERRORS error(s)"
fi