diff --git a/Configurations/master.conf b/Configurations/master.conf index f8d5bce..d30024c 100644 --- a/Configurations/master.conf +++ b/Configurations/master.conf @@ -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 ────────────────────────────────────────────────────────────────────────── # ============================================================================================== diff --git a/Media/play_state_sync.sh b/Media/play_state_sync.sh new file mode 100755 index 0000000..a5e607e --- /dev/null +++ b/Media/play_state_sync.sh @@ -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