Files
Varaverk/Media/play_state_sync.sh
T

581 lines
26 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[@]}"
require_partnership
# ==============================================================================================
# ━━━ 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}"
# Played=true is the primary key — a played server always beats a non-played server
# regardless of epoch. A resumable item with a newer LastPlayedDate must not become
# authority over a played item, as pushing resume ticks to a played server resets
# the played status on some Emby/Jellyfin versions.
if ( [[ "$_pf" == "true" ]] && [[ "$_auth_pf" != "true" ]] ) || \
( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -gt "$_auth_epoch" ]] ) || \
( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -eq "$_auth_epoch" ]] && [[ "$_pc" -gt "$_auth_pcount" ]] ) || \
( [[ "$_pf" == "$_auth_pf" ]] && [[ "$_e" -eq "$_auth_epoch" ]] && [[ "$_pc" -eq "$_auth_pcount" ]] && [[ "$_tk" -gt "$_auth_ticks" ]] ); 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 — never push ticks to a server that already
# has this item marked played. Writing ticks via UserData can reset Played=false.
if [[ "${E_PLAYED[$_si]:-false}" == "true" ]]; then
log " SKIP $_pkey${SRV_NAME[$_si]} already played, not overwriting with resume ticks"
(( TOTAL_SKIPPED++ ))
continue
fi
_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