From d058cf15c9f325c11a6fbb692736d53b6b7fba99 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 22 Jun 2026 23:42:39 -0400 Subject: [PATCH] =?UTF-8?q?Add=20favorite=20sync=20to=20play=5Fstate=5Fsyn?= =?UTF-8?q?c=20=E2=80=94=20union=20semantics,=20music=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favorites on any server propagate to all others; never unmarks. Covers MusicArtist, MusicAlbum, Movie, Series (Audio tracks future). Provider map extended to include FAV_TYPES alongside SYNC_TYPES, with Series/Episode TVDB IDs namespaced to avoid collisions. --- Deployment/master.conf.template | 1 + Media/play_state_sync.sh | 171 ++++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 9 deletions(-) diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index e3a3c8f..c197d31 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -691,6 +691,7 @@ PLAY_SYNC_REMOTE=true # sync across hosts via Tailscale PLAY_SYNC_DAYS=90 # days back to check for played items (0 = all time) PLAY_SYNC_TYPES="Movie,Episode" # Audio excluded — music library too large; favorites handled separately + PLAY_SYNC_FAV_TYPES="MusicArtist,MusicAlbum,Movie,Series" # union sync — never unmarks; Audio tracks future # ━━━ Play State Sync — Handback ━━━ # play_state_sync runs before DNS cutover so users land on current watch state. diff --git a/Media/play_state_sync.sh b/Media/play_state_sync.sh index 40634b7..1b10101 100755 --- a/Media/play_state_sync.sh +++ b/Media/play_state_sync.sh @@ -94,9 +94,10 @@ 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,Audio}" +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} remote=${PLAY_SYNC_REMOTE:-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; } @@ -287,7 +288,15 @@ declare -A PROV_LOOKUP # "si|tvdb.{id}" | "si|imdb.{id}" | "si|mb.{id}" → it # pushing items to positions far beyond the first pages. Query each type separately so # each list sorts within its own type and items appear where expected. _PROV_PAGE=2000 -IFS=',' read -ra _prov_types <<< "$SYNC_TYPES" +# Combine play state types + fav types for the map build (deduplicated) +declare -A _prov_seen_dedup +_prov_types=() +IFS=',' read -ra _prov_combined <<< "${SYNC_TYPES},${FAV_TYPES}" +for _t in "${_prov_combined[@]}"; do + [[ -n "${_prov_seen_dedup[$_t]:-}" ]] && continue + _prov_seen_dedup[$_t]=1; _prov_types+=("$_t") +done +unset _prov_seen_dedup _prov_combined for _psi in $(seq 0 $(( _srv_count - 1 ))); do [[ -z "${SRV_URL[$_psi]}" ]] && continue @@ -303,17 +312,29 @@ for _psi in $(seq 0 $(( _srv_count - 1 ))); do [[ "$_prov_total" -lt 0 ]] && _prov_total=$(echo "$_page" | jq '.TotalRecordCount // 0' 2>/dev/null || echo 0) _prov_count=$(echo "$_page" | jq '.Items | length' 2>/dev/null || echo 0) [[ "$_prov_count" -eq 0 ]] && break - 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" + while IFS=$'\t' read -r _pid _ptype _ptvdb _pimdb _ptmdb _pmbtrack _pmbartist _pmbalbum; do + [[ "$_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" + [[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && PROV_LOOKUP["${_psi}|mb.artist.${_pmbartist}"]="$_pid" + [[ "$_pmbalbum" != "null" && -n "$_pmbalbum" ]] && PROV_LOOKUP["${_psi}|mb.album.${_pmbalbum}"]="$_pid" + # tvdb: namespace by type so Series and Episode IDs don't collide + if [[ "$_ptvdb" != "null" && -n "$_ptvdb" ]]; then + case "$_ptype" in + Series) PROV_LOOKUP["${_psi}|tvdb.series.${_ptvdb}"]="$_pid" ;; + Episode) PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;; + *) PROV_LOOKUP["${_psi}|tvdb.${_ptvdb}"]="$_pid" ;; + esac + fi done < <(echo "$_page" | jq -r '.Items[] | [ .Id, + .Type, (.ProviderIds.Tvdb // "null"), (.ProviderIds.Imdb // "null"), (.ProviderIds.Tmdb // "null"), - (.ProviderIds.MusicBrainzTrackId // "null") + (.ProviderIds.MusicBrainzTrackId // "null"), + (.ProviderIds.MusicBrainzArtistId // "null"), + (.ProviderIds.MusicBrainzAlbumId // "null") ] | @tsv' 2>/dev/null) _prov_start=$(( _prov_start + _prov_count )) [[ "$_prov_total" -gt 0 && "$_prov_start" -ge "$_prov_total" ]] && break @@ -584,6 +605,136 @@ for lname in "${!USER_MAP[@]}"; do unset ITEM_MAP U_IDX U_UID done +# ============================================================================================== +# ━━━ Favorite Sync ━━━ +# ============================================================================================== +# Union semantics — if favorited on any server, sync to all others. Never unmarks. +# Covers MusicArtist, MusicAlbum, Movie, Series (configured via PLAY_SYNC_FAV_TYPES). +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 + + declare -A UF_IDX UF_UID + for _pair in "${_pairs[@]}"; do + IFS=':' read -r _si _ui <<< "$_pair" + UF_IDX["$_si"]="$_si" + UF_UID["$_si"]="$_ui" + done + + declare -A FAV_MAP # provider_key → space-separated "si:iid" pairs + + 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") + [[ -z "$_resp" ]] && continue + _fcount=$(echo "$_resp" | jq '.Items | length' 2>/dev/null || echo 0) + [[ "$_fcount" -eq 0 ]] && continue + log " ${SRV_NAME[$_si]} — $_fcount ${_ftype} favorite(s) for $lname" + + while IFS=$'\t' read -r _iid _itype _pimdb _ptmdb _ptvdb _pmbartist _pmbalbum _pmbtrack; do + _pkey="" + case "$_itype" in + Movie) + [[ "$_pimdb" != "null" && -n "$_pimdb" ]] && _pkey="imdb:${_pimdb}" + [[ -z "$_pkey" && "$_ptmdb" != "null" && -n "$_ptmdb" ]] && _pkey="tmdb:movie:${_ptmdb}" + ;; + Series) + [[ "$_ptvdb" != "null" && -n "$_ptvdb" ]] && _pkey="tvdb:series:${_ptvdb}" + ;; + MusicArtist) + [[ "$_pmbartist" != "null" && -n "$_pmbartist" ]] && _pkey="mb:artist:${_pmbartist}" + ;; + MusicAlbum) + [[ "$_pmbalbum" != "null" && -n "$_pmbalbum" ]] && _pkey="mb:album:${_pmbalbum}" + ;; + Audio) + [[ "$_pmbtrack" != "null" && -n "$_pmbtrack" ]] && _pkey="mb:track:${_pmbtrack}" + ;; + esac + [[ -z "$_pkey" ]] && continue + + if [[ -n "${FAV_MAP[$_pkey]:-}" ]]; then + FAV_MAP[$_pkey]+=" ${_si}:${_iid}" + else + FAV_MAP[$_pkey]="${_si}:${_iid}" + fi + done < <(echo "$_resp" | jq -r '.Items[] | [ + .Id, .Type, + (.ProviderIds.Imdb // "null"), + (.ProviderIds.Tmdb // "null"), + (.ProviderIds.Tvdb // "null"), + (.ProviderIds.MusicBrainzArtistId // "null"), + (.ProviderIds.MusicBrainzAlbumId // "null"), + (.ProviderIds.MusicBrainzTrackId // "null") + ] | @tsv' 2>/dev/null) + done + done + + for _pkey in "${!FAV_MAP[@]}"; do + declare -A _fhave _fiid + for _entry in ${FAV_MAP[$_pkey]}; do + IFS=':' read -r _si _iid <<< "$_entry" + _fhave[$_si]=true + _fiid[$_si]="$_iid" + done + + for _si in "${!UF_IDX[@]}"; do + [[ "${_fhave[$_si]:-false}" == "true" ]] && (( FAV_TOTAL_SKIPPED++ )) && continue + _uid="${UF_UID[$_si]}" + + # Look up item ID on target server via PROV_LOOKUP + _iid="" + _ptype="${_pkey%%:*}" + case "$_ptype" in + imdb) _iid="${PROV_LOOKUP[${_si}|imdb.${_pkey#imdb:}]:-}" ;; + tmdb) _iid="${PROV_LOOKUP[${_si}|tmdb.${_pkey#tmdb:movie:}]:-}" ;; + tvdb) _iid="${PROV_LOOKUP[${_si}|tvdb.series.${_pkey#tvdb:series:}]:-}" ;; + mb) + _mbsub="${_pkey#mb:}" + case "${_mbsub%%:*}" in + artist) _iid="${PROV_LOOKUP[${_si}|mb.artist.${_mbsub#artist:}]:-}" ;; + album) _iid="${PROV_LOOKUP[${_si}|mb.album.${_mbsub#album:}]:-}" ;; + track) _iid="${PROV_LOOKUP[${_si}|mb.${_mbsub#track:}]:-}" ;; + esac + ;; + esac + + if [[ -z "$_iid" ]]; then + log " SKIP FAV $_pkey → ${SRV_NAME[$_si]} not in library" + continue + fi + + log " FAV $_pkey → ${SRV_NAME[$_si]} ($lname)" + if [[ "$DRY_RUN" == false ]]; then + _http=$(_api_post "${SRV_URL[$_si]}" "${SRV_KEY[$_si]}" \ + "Users/${_uid}/FavoriteItems/${_iid}") + if [[ "$_http" == "200" || "$_http" == "201" ]]; then + success " ✓ FAV $_pkey → ${SRV_NAME[$_si]}" + (( FAV_TOTAL_SYNCED++ )) + else + warn " ✗ FAV $_pkey → ${SRV_NAME[$_si]} failed (HTTP ${_http:-err})" + (( FAV_TOTAL_ERRORS++ )) + fi + else + echo " DRY RUN: would favorite $_pkey → ${SRV_NAME[$_si]} user=$lname" + (( FAV_TOTAL_SYNCED++ )) + fi + done + + unset _fhave _fiid + done + + unset FAV_MAP UF_IDX UF_UID +done + # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== @@ -594,6 +745,8 @@ 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" +echo "" +echo " Favorites — synced: $FAV_TOTAL_SYNCED skipped: $FAV_TOTAL_SKIPPED errors: $FAV_TOTAL_ERRORS" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes written"