diff --git a/Media/play_state_sync.sh b/Media/play_state_sync.sh index 8f279ef..b3034e7 100755 --- a/Media/play_state_sync.sh +++ b/Media/play_state_sync.sh @@ -77,10 +77,15 @@ # 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 excluded, music library too large; favorites handled separately) +# PLAY_SYNC_PROBE Skip all per-item processing when no play/resume/favorite +# state changed since the last successful run (default: true). +# The raw API responses are hashed and compared against the +# fingerprint stored in STATE_DIR — fetches still happen every +# run, so nothing can be missed. +# PLAY_SYNC_PROBE_MAX_AGE_HOURS Force a full comparison when the stored fingerprint is +# older than this many hours regardless of the hash (default: 24) # # ============================================================================================== # RUNTIME MODES @@ -96,7 +101,7 @@ # Show configured servers, reachability, and user counts. # # play_state_sync.sh --full -# Ignore PLAY_SYNC_DAYS — sync all played items (may be slow). +# Bypass the change probe — always run the full comparison. # # play_state_sync.sh --log # Verbose output — show each item comparison. @@ -126,11 +131,9 @@ parse_args "${_FILTERED[@]}" # ============================================================================================== [[ "${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}" FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}" -[[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0 -log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true}" +log "$ICON_GEAR Config: types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true} probe=${PLAY_SYNC_PROBE:-true}" command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; } @@ -231,17 +234,6 @@ _api_post() { 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 )) @@ -254,7 +246,7 @@ 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 Change probe: ${PLAY_SYNC_PROBE:-true}" echo "$ICON_GEAR Item types: $SYNC_TYPES" echo "" for i in $(seq 0 $(( _srv_count - 1 ))); do @@ -277,7 +269,7 @@ fi # ============================================================================================== 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" +[[ "$FULL_SYNC" == true ]] && log "Full sync mode — change probe bypassed" START=$(date +%s) TOTAL_SYNCED=0 @@ -317,6 +309,77 @@ for i in $(seq 0 $(( _srv_count - 1 ))); do done < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null) done +# ── Step 2.3: Fetch play/resume/favorite state for every matched user ──────── +# Raw responses are cached for the sync passes below and hashed for the change +# probe. Fetching costs seconds — the per-item comparison is what costs minutes, +# so it only runs when a response actually changed since the last successful run. +declare -A RESP_STATE # "lname|si" → deduplicated played+resumable items JSON +declare -A RESP_FAV # "lname|si|ftype" → favorites response JSON + +IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES" + +for lname in "${!USER_MAP[@]}"; do + read -ra _pairs <<< "${USER_MAP[$lname]}" + [[ "${#_pairs[@]}" -lt 2 ]] && continue + + for _pair in "${_pairs[@]}"; do + IFS=':' read -r _si _uid <<< "$_pair" + + # 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" + RESP_STATE["$lname|$_si"]="" + else + # 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":[]}' + RESP_STATE["$lname|$_si"]=$(printf '%s\n%s' "$_resp" "$_resp2" | jq -s \ + '[.[0].Items // [], .[1].Items // []] | add // [] | unique_by(.Id)' 2>/dev/null) + fi + + for _ftype in "${_fav_type_list[@]}"; do + RESP_FAV["$lname|$_si|$_ftype"]=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \ + "Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds") + done + done +done + +# ── Step 2.4: Change probe — skip the comparison when nothing changed ──────── +# Failed fetches hash as empty strings, so reachability transitions also read +# as changes and trigger a full pass once the server comes back. +PROBE_FILE="$STATE_DIR/play_state_sync_probe" +CUR_HASH=$( + { + while IFS= read -r _k; do + printf '%s:' "$_k"; printf '%s' "${RESP_STATE[$_k]}" | md5sum + done < <(printf '%s\n' "${!RESP_STATE[@]}" | sort) + while IFS= read -r _k; do + printf '%s:' "$_k"; printf '%s' "${RESP_FAV[$_k]}" | md5sum + done < <(printf '%s\n' "${!RESP_FAV[@]}" | sort) + } | md5sum | awk '{print $1}' +) + +if [[ "${PLAY_SYNC_PROBE:-true}" == "true" && "$FULL_SYNC" == false && "$DRY_RUN" == false && -f "$PROBE_FILE" ]]; then + _prev_hash=$(sed -n '1p' "$PROBE_FILE" 2>/dev/null) + _prev_epoch=$(sed -n '2p' "$PROBE_FILE" 2>/dev/null) + [[ "$_prev_epoch" =~ ^[0-9]+$ ]] || _prev_epoch=0 + _probe_max_age=$(( ${PLAY_SYNC_PROBE_MAX_AGE_HOURS:-24} * 3600 )) + if [[ "$CUR_HASH" == "$_prev_hash" ]] && (( $(date +%s) - ${_prev_epoch:-0} < _probe_max_age )); then + END=$(date +%s) + echo "" + echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC SUMMARY ━━━━━" + echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" + echo "$ICON_DONE No play/resume/favorite changes since last sync — comparison skipped" + success "Done ✅" + exit 0 + fi +fi + # ── 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 @@ -382,13 +445,6 @@ for _psi in $(seq 0 $(( _srv_count - 1 ))); do 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]}" @@ -411,29 +467,14 @@ for lname in "${!USER_MAP[@]}"; do 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) + _combined="${RESP_STATE[$lname|$_si]:-}" + [[ -z "$_combined" ]] && continue _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 + while IFS=$'\t' read -r iid itype season ep imdb tmdb tvdb mbtrack played ticks lplayed pcount epoch; do # Build canonical provider key _pkey="" case "$itype" in @@ -451,7 +492,7 @@ for lname in "${!USER_MAP[@]}"; do esac [[ -z "$_pkey" ]] && continue - _epoch=$(_iso_to_epoch "$lplayed") + _epoch="${epoch:-0}" _entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}" if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then @@ -472,7 +513,11 @@ for lname in "${!USER_MAP[@]}"; do (.UserData.Played // false | tostring), (.UserData.PlaybackPositionTicks // 0 | tostring), (.UserData.LastPlayedDate // "null"), - (.UserData.PlayCount // 0 | tostring) + (.UserData.PlayCount // 0 | tostring), + ((.UserData.LastPlayedDate // null) | if . == null then "0" + else (((sub("\\.[0-9]+"; "") | sub("[+-][0-9]{2}:?[0-9]{2}$"; "Z") + | if endswith("Z") then . else . + "Z" end + | fromdateiso8601)? // 0) | tostring) end) ] | @tsv' 2>/dev/null) done @@ -653,8 +698,6 @@ 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 @@ -671,8 +714,7 @@ for lname in "${!USER_MAP[@]}"; do 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") + _resp="${RESP_FAV[$lname|$_si|$_ftype]:-}" [[ -z "$_resp" ]] && continue _fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0) [[ "$_fcount" -eq 0 ]] && continue @@ -789,6 +831,13 @@ echo " Favorites — synced: $FAV_TOTAL_SYNCED skipped: $FAV_TOTAL_SKIPPED er TOTAL_ALL_ERRORS=$(( TOTAL_ERRORS + FAV_TOTAL_ERRORS )) +# Fingerprint is the PRE-sync state — our own writes above changed the targets, +# so the next run does one more full pass and then settles into probe skips. +# Re-hashing post-sync instead would swallow plays that landed mid-run. +if [[ "$DRY_RUN" == false && "$TOTAL_ALL_ERRORS" -eq 0 ]]; then + printf '%s\n%s\n' "$CUR_HASH" "$START" > "$PROBE_FILE" 2>/dev/null || true +fi + if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes written" exit 0