Play state sync: replace per-item date-fork and PLAY_SYNC_DAYS window with jq epoch parsing and a response-hash change probe that skips the full comparison when nothing changed since the last run
This commit is contained in:
+99
-50
@@ -77,10 +77,15 @@
|
|||||||
# PLAY_SYNC_ENABLED Master toggle (default: true)
|
# PLAY_SYNC_ENABLED Master toggle (default: true)
|
||||||
# PLAY_SYNC_REMOTE Sync across all hosts via Tailscale (default: true)
|
# PLAY_SYNC_REMOTE Sync across all hosts via Tailscale (default: true)
|
||||||
# false = local servers only (this host's Emby + Jellyfin)
|
# 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 —
|
# PLAY_SYNC_TYPES Comma-separated item types to sync (default: Movie,Episode —
|
||||||
# Audio excluded, music library too large; favorites handled separately)
|
# 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
|
# RUNTIME MODES
|
||||||
@@ -96,7 +101,7 @@
|
|||||||
# Show configured servers, reachability, and user counts.
|
# Show configured servers, reachability, and user counts.
|
||||||
#
|
#
|
||||||
# play_state_sync.sh --full
|
# 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
|
# play_state_sync.sh --log
|
||||||
# Verbose output — show each item comparison.
|
# 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
|
[[ "${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}"
|
SYNC_TYPES="${PLAY_SYNC_TYPES:-Movie,Episode}"
|
||||||
FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}"
|
FAV_TYPES="${PLAY_SYNC_FAV_TYPES:-MusicArtist,MusicAlbum,Movie,Series}"
|
||||||
[[ "$FULL_SYNC" == true ]] && SYNC_DAYS=0
|
log "$ICON_GEAR Config: types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true} probe=${PLAY_SYNC_PROBE:-true}"
|
||||||
log "$ICON_GEAR Config: days=${SYNC_DAYS} types=${SYNC_TYPES} favs=${FAV_TYPES} remote=${PLAY_SYNC_REMOTE:-true}"
|
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
|
command -v jq >/dev/null 2>&1 || { error "jq is required but not installed"; exit 1; }
|
||||||
|
|
||||||
@@ -231,17 +234,6 @@ _api_post() {
|
|||||||
fi
|
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 → seconds (1 tick = 100ns, 10_000_000 ticks = 1s)
|
||||||
_ticks_to_sec() {
|
_ticks_to_sec() {
|
||||||
echo $(( ${1:-0} / 10000000 ))
|
echo $(( ${1:-0} / 10000000 ))
|
||||||
@@ -254,7 +246,7 @@ if [[ "$SHOW_STATUS" == true ]]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━"
|
echo "━━━━━ $ICON_SUMMARY PLAY STATE SYNC STATUS ━━━━━"
|
||||||
echo "$ICON_GEAR Remote sync: ${PLAY_SYNC_REMOTE:-true}"
|
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 "$ICON_GEAR Item types: $SYNC_TYPES"
|
||||||
echo ""
|
echo ""
|
||||||
for i in $(seq 0 $(( _srv_count - 1 ))); do
|
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') ━━━"
|
echo "━━━ $ICON_SYNC Play State Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no state will be written"
|
[[ "$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)
|
START=$(date +%s)
|
||||||
TOTAL_SYNCED=0
|
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 < <(echo "${SRV_USERS[$i]}" | jq -r '.[] | [.Id, .Name] | @tsv' 2>/dev/null)
|
||||||
done
|
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 ─────────────────────
|
# ── Step 2.5: Pre-build provider ID → item ID lookup map ─────────────────────
|
||||||
# AnyProviderIdEquals is broken in Jellyfin 10.11+ (ignores the filter entirely).
|
# 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
|
# 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
|
done
|
||||||
|
|
||||||
# ── Step 3: Sync per matched user ────────────────────────────────────────────
|
# ── 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
|
for lname in "${!USER_MAP[@]}"; do
|
||||||
read -ra _pairs <<< "${USER_MAP[$lname]}"
|
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
|
declare -A ITEM_MAP # provider_key → JSON per-server data
|
||||||
|
|
||||||
for _si in "${!U_IDX[@]}"; do
|
for _si in "${!U_IDX[@]}"; do
|
||||||
_uid="${U_UID[$_si]}"
|
_combined="${RESP_STATE[$lname|$_si]:-}"
|
||||||
# All played items — no limit, covers both date-stamped and batch-marked (null date) entries
|
[[ -z "$_combined" ]] && continue
|
||||||
_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)
|
_count=$(echo "$_combined" | jq 'length' 2>/dev/null || echo 0)
|
||||||
log " ${SRV_NAME[$_si]} — $_count item(s) with state for $lname"
|
log " ${SRV_NAME[$_si]} — $_count item(s) with state for $lname"
|
||||||
|
|
||||||
# Build item lookup by provider key
|
# 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
|
# Build canonical provider key
|
||||||
_pkey=""
|
_pkey=""
|
||||||
case "$itype" in
|
case "$itype" in
|
||||||
@@ -451,7 +492,7 @@ for lname in "${!USER_MAP[@]}"; do
|
|||||||
esac
|
esac
|
||||||
[[ -z "$_pkey" ]] && continue
|
[[ -z "$_pkey" ]] && continue
|
||||||
|
|
||||||
_epoch=$(_iso_to_epoch "$lplayed")
|
_epoch="${epoch:-0}"
|
||||||
_entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}"
|
_entry="${_si}|${iid}|${played}|${pcount}|${ticks}|${_epoch}|${lplayed}"
|
||||||
|
|
||||||
if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then
|
if [[ -n "${ITEM_MAP[$_pkey]}" ]]; then
|
||||||
@@ -472,7 +513,11 @@ for lname in "${!USER_MAP[@]}"; do
|
|||||||
(.UserData.Played // false | tostring),
|
(.UserData.Played // false | tostring),
|
||||||
(.UserData.PlaybackPositionTicks // 0 | tostring),
|
(.UserData.PlaybackPositionTicks // 0 | tostring),
|
||||||
(.UserData.LastPlayedDate // "null"),
|
(.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)
|
] | @tsv' 2>/dev/null)
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -653,8 +698,6 @@ FAV_TOTAL_SYNCED=0
|
|||||||
FAV_TOTAL_SKIPPED=0
|
FAV_TOTAL_SKIPPED=0
|
||||||
FAV_TOTAL_ERRORS=0
|
FAV_TOTAL_ERRORS=0
|
||||||
|
|
||||||
IFS=',' read -ra _fav_type_list <<< "$FAV_TYPES"
|
|
||||||
|
|
||||||
for lname in "${!USER_MAP[@]}"; do
|
for lname in "${!USER_MAP[@]}"; do
|
||||||
read -ra _pairs <<< "${USER_MAP[$lname]}"
|
read -ra _pairs <<< "${USER_MAP[$lname]}"
|
||||||
[[ "${#_pairs[@]}" -lt 2 ]] && continue
|
[[ "${#_pairs[@]}" -lt 2 ]] && continue
|
||||||
@@ -671,8 +714,7 @@ for lname in "${!USER_MAP[@]}"; do
|
|||||||
for _si in "${!UF_IDX[@]}"; do
|
for _si in "${!UF_IDX[@]}"; do
|
||||||
_uid="${UF_UID[$_si]}"
|
_uid="${UF_UID[$_si]}"
|
||||||
for _ftype in "${_fav_type_list[@]}"; do
|
for _ftype in "${_fav_type_list[@]}"; do
|
||||||
_resp=$(_api_get "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \
|
_resp="${RESP_FAV[$lname|$_si|$_ftype]:-}"
|
||||||
"Users/${_uid}/Items?Recursive=true&IncludeItemTypes=${_ftype}&Filters=IsFavorite&Fields=ProviderIds")
|
|
||||||
[[ -z "$_resp" ]] && continue
|
[[ -z "$_resp" ]] && continue
|
||||||
_fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0)
|
_fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0)
|
||||||
[[ "$_fcount" -eq 0 ]] && continue
|
[[ "$_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 ))
|
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
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
warn "DRY RUN — no changes written"
|
warn "DRY RUN — no changes written"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Reference in New Issue
Block a user