Add play_state_sync.sh — sync watched state across Emby + Jellyfin
Syncs played/resume state bidirectionally across all configured Emby and Jellyfin servers. Newest LastPlayedDate wins; users matched by name (case-insensitive); unmatched users skipped cleanly. Matching: IMDb/TMDB for movies, TVDB+s/e for episodes, MusicBrainz track ID for audio. Also syncs resume positions for partial plays. Config: PLAY_SYNC_ENABLED, PLAY_SYNC_DAYS (default 90), PLAY_SYNC_TYPES. Modes: --dry-run, --status, --full (ignore day window), --log. Wired into DAILY_MAINTENANCE_SCRIPTS after arr cleanup.
This commit is contained in:
@@ -353,6 +353,7 @@
|
||||
"Media/lidarr_missing_art.sh" # fetch missing album/artist artwork (HOST1 only — self-guards)
|
||||
"Media/radarr_tmdb_removed.sh" # remove movies dropped from TMDb
|
||||
"Media/sonarr_tvdb_removed.sh" # remove series dropped from TVDB
|
||||
"Media/play_state_sync.sh" # sync watched/resume state across Emby + Jellyfin
|
||||
"Docker_Essentials/docker_update.sh" # pull container image updates before restart
|
||||
"Docker_Essentials/docker_daily_restart.sh" # daily container restarts — runs last
|
||||
)
|
||||
@@ -1244,6 +1245,14 @@
|
||||
EMBY_REPORT_DAYS=7 # days to include in the report period
|
||||
EMBY_REPORT_TOP_N=10 # number of top content items to show
|
||||
|
||||
# ━━━ Play State Sync ━━━
|
||||
# Syncs watched state and resume positions across all configured Emby and Jellyfin servers.
|
||||
# Newest LastPlayedDate wins — no data is ever lost. Users matched by name (case-insensitive).
|
||||
# Called via DAILY_MAINTENANCE_SCRIPTS. Run manually: Media/play_state_sync.sh
|
||||
PLAY_SYNC_ENABLED=true
|
||||
PLAY_SYNC_DAYS=90 # how many days back to look (0 = all time, slow on large libraries)
|
||||
PLAY_SYNC_TYPES="Movie,Episode,Audio" # item types to sync
|
||||
|
||||
# ==============================================================================================
|
||||
# ── RESOURCE MANAGER ──────────────────────────────────────────────────────────────────────────
|
||||
# ==============================================================================================
|
||||
|
||||
Executable
+479
@@ -0,0 +1,479 @@
|
||||
#!/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*_EMBY_URL Emby server URL
|
||||
# HOST*_EMBY_API_KEY Emby API key
|
||||
# HOST*_JELLYFIN_URL Jellyfin server URL
|
||||
# HOST*_JELLYFIN_API_KEY Jellyfin API key
|
||||
#
|
||||
# master.conf
|
||||
#
|
||||
# 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)
|
||||
# PLAY_SYNC_ENABLED Master toggle (default: true)
|
||||
#
|
||||
# ==============================================================================================
|
||||
# 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" ]] && log "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
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
|
||||
|
||||
acquire_lock
|
||||
|
||||
# ── Build server list ─────────────────────────────────────────────────────────
|
||||
declare -a SRV_NAME SRV_URL SRV_KEY SRV_TYPE
|
||||
_srv_count=0
|
||||
|
||||
_add_server() {
|
||||
local name="$1" url="$2" key="$3" type="$4"
|
||||
[[ -z "$url" || -z "$key" ]] && return
|
||||
SRV_NAME[$_srv_count]="$name"
|
||||
SRV_URL[$_srv_count]="$url"
|
||||
SRV_KEY[$_srv_count]="$key"
|
||||
SRV_TYPE[$_srv_count]="$type"
|
||||
(( _srv_count++ ))
|
||||
}
|
||||
|
||||
# Read from MEDIA_SERVERS array if set, otherwise fall back to individual vars
|
||||
if [[ ${#MEDIA_SERVERS[@]} -gt 0 ]]; then
|
||||
for _entry in "${MEDIA_SERVERS[@]}"; do
|
||||
IFS='|' read -r _name _url _key _type <<< "$_entry"
|
||||
[[ "$_type" == "emby" || "$_type" == "jellyfin" ]] && _add_server "$_name" "$_url" "$_key" "$_type"
|
||||
done
|
||||
else
|
||||
_add_server "${EMBY_CONTAINER:-Emby}" "${EMBY_URL:-}" "${EMBY_API_KEY:-}" "emby"
|
||||
_add_server "${JELLYFIN_CONTAINER:-Jellyfin}" "${JELLYFIN_URL:-}" "${JELLYFIN_API_KEY:-}" "jellyfin"
|
||||
fi
|
||||
|
||||
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 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 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]}"
|
||||
_endpoint="Users/${_uid}/Items?Recursive=true&Fields=ProviderIds,UserData,Type,ParentIndexNumber,IndexNumber,SeriesName&IncludeItemTypes=${SYNC_TYPES}&Filters=IsPlayed${_date_filter}&Limit=5000"
|
||||
_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
|
||||
|
||||
# Also fetch items with resume position (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${_date_filter}&Limit=500"
|
||||
_resp2=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" "$_endpoint2")
|
||||
|
||||
# Combine and deduplicate by Id
|
||||
_combined=$(echo "${_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
|
||||
_auth_si=""
|
||||
_auth_epoch=0
|
||||
_auth_pcount=0
|
||||
_auth_ticks=0
|
||||
|
||||
for _si in "${!E_SIDX[@]}"; do
|
||||
_e="${E_EPOCH[$_si]:-0}"
|
||||
_pc="${E_PCOUNT[$_si]:-0}"
|
||||
_tk="${E_TICKS[$_si]:-0}"
|
||||
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" ]]; then
|
||||
_auth_si="$_si"
|
||||
_auth_epoch="$_e"
|
||||
_auth_pcount="$_pc"
|
||||
_auth_ticks="$_tk"
|
||||
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
|
||||
if [[ "$_their_epoch" -ge "$_auth_epoch" ]] && \
|
||||
[[ "$_their_played" == "$_auth_played" ]]; 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%%:*}"
|
||||
_pval="${_pkey##*:}"
|
||||
case "$_ptype" in
|
||||
imdb) _search_field="imdb.${_pval}" ;;
|
||||
tmdb) _search_field="tmdb.${_pval##movie:}" ;;
|
||||
tvdb) _search_field="tvdb.${_pval%%:*}" ;;
|
||||
mb) _search_field="" ;; # skip music if not found
|
||||
esac
|
||||
|
||||
if [[ -n "$_search_field" ]]; then
|
||||
_iid=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
|
||||
"Items?AnyProviderIdEquals=${_search_field}&Recursive=true&Fields=ProviderIds&Limit=1" 2>/dev/null \
|
||||
| jq -r '.Items[0].Id // empty' 2>/dev/null)
|
||||
fi
|
||||
[[ -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=""
|
||||
[[ "$_auth_lplayed" != "null" && -n "$_auth_lplayed" ]] && \
|
||||
_date_param="?DatePlayed=${_auth_lplayed//[: ]/%3A}"
|
||||
_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
|
||||
Reference in New Issue
Block a user