diff --git a/Media/playback_aware_radarr_discovery.sh b/Media/playback_aware_radarr_discovery.sh index 28a5bc0..562c58d 100755 --- a/Media/playback_aware_radarr_discovery.sh +++ b/Media/playback_aware_radarr_discovery.sh @@ -62,7 +62,7 @@ # CONFIGURATION (master.conf) # ============================================================================================== # -# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 60) +# RADARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 50) # RADARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 30) # RADARR_DISCOVERY_MAX_SEEDS — max seed movies from Stage 1 (default: 5) # RADARR_DISCOVERY_MAX_ADDS — max movies to add per run (default: 5) @@ -137,6 +137,8 @@ SEED_LIBRARIES=("${RADARR_DISCOVERY_SEED_LIBRARIES[@]:-Movies}") [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr" +_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; } + # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== @@ -150,7 +152,7 @@ if [[ "$SHOW_STATUS" == true ]]; then echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days" echo "$ICON_GEAR Max seeds: ${MAX_SEEDS}" echo "$ICON_GEAR Max adds: ${MAX_ADDS}" - echo "$ICON_GEAR Min rating: ${MIN_RATING} ($(echo "scale=1; $MIN_RATING/10" | bc)/10 TMDB)" + echo "$ICON_GEAR Min rating: ${MIN_RATING} ($(_fmt_rating "$MIN_RATING")/10 TMDB)" echo "$ICON_GEAR Min votes: ${MIN_VOTE_COUNT}" echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days" echo "$ICON_GEAR Seed libs: ${SEED_LIBRARIES[*]}" @@ -219,18 +221,16 @@ _recency_score() { fi } -# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78) -_rating_score_s1() { - local v="$1" - if (( v >= 80 )); then echo 30 - elif (( v >= 75 )); then echo 25 - elif (( v >= 70 )); then echo 18 - elif (( v >= 65 )); then echo 12 - elif (( v >= 60 )); then echo 8 - else echo 3 +# Stage 1: play frequency score (max 50) — complements recency (max 50) for 100 total +_freq_score() { + local c="$1" + if (( c >= 4 )); then echo 50 + elif (( c >= 2 )); then echo 35 + else echo 20 fi } +# vote_avg_int = vote_average × 10 as integer (e.g. 7.8 → 78) _rating_score_s2() { local v="$1" if (( v >= 80 )); then echo 40 @@ -276,8 +276,8 @@ CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ') TODAY_EPOCH=$(date +%s) TODAY=$(date +%Y-%m-%d) -declare -A WATCHED_MOVIES # tmdb_id → "title|last_played_date|vote_avg_int|vote_count" -declare -A EMBY_TMDB_IDS # tmdb_id → 1 (all movies in Emby, for Stage 2 filter) +# Build TMDB index of all movies in Emby — used in Stage 2 to filter already-owned movies +declare -A EMBY_TMDB_IDS # tmdb_id → 1 for lib_name in "${SEED_LIBRARIES[@]}"; do lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null) @@ -285,37 +285,73 @@ for lib_name in "${SEED_LIBRARIES[@]}"; do warn "Emby library not found: $lib_name" continue fi - log "Scanning library: $lib_name (ItemId: $lib_id)" - - LIB_JSON=$(_emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds,UserData&SortBy=DatePlayed&SortOrder=Descending&Limit=200") || { - warn "Could not fetch movies from library: $lib_name" + log "Indexing library: $lib_name (ItemId: $lib_id)" + LIB_JSON=$(_emby_api "Items?ParentId=${lib_id}&IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000") || { + warn "Could not index library: $lib_name" continue } - - while IFS='|' read -r name tmdb_id play_count last_played vote_avg vote_count; do - [[ -z "$name" || "$name" == "null" ]] && continue - [[ -z "$tmdb_id" || "$tmdb_id" == "null" || "$tmdb_id" == "" ]] && continue - EMBY_TMDB_IDS["$tmdb_id"]=1 - - [[ "$play_count" -le 0 ]] 2>/dev/null && continue - [[ -z "$last_played" || "$last_played" == "null" ]] && continue - [[ "$last_played" < "$CUTOFF_ISO" ]] && continue - - vote_avg_int=$(echo "$vote_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null) - vote_avg_int=${vote_avg_int:-0} - vote_count=${vote_count:-0} - - WATCHED_MOVIES["$tmdb_id"]="${name}|${last_played}|${vote_avg_int}|${vote_count}" - done < <(echo "$LIB_JSON" | jq -r '.Items[] | - [ - .Name, - (.ProviderIds.Tmdb // ""), - (.UserData.PlayCount // 0 | tostring), - (.UserData.LastPlayedDate // ""), - (.CommunityRating // 0 | tostring), - (.VoteCount // 0 | tostring) - ] | join("|")' 2>/dev/null) + while IFS= read -r tmdb_id; do + [[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && EMBY_TMDB_IDS["$tmdb_id"]=1 + done < <(echo "$LIB_JSON" | jq -r '.Items[].ProviderIds.Tmdb // empty' 2>/dev/null) done +log "${#EMBY_TMDB_IDS[@]} movies in Emby TMDB index" + +# Fetch recent play completions from the server-level activity log. +# Each entry includes an ItemId — batch-fetch those items to determine type (Movie vs. Music/TV). +# SortBy=DatePlayed on Items requires UserId context and errors without one; the activity log +# is server-scoped and doesn't have that limitation. +ACTIVITY_JSON=$(_emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || { + error "Could not fetch Emby activity log" + exit 1 +} + +declare -A ITEM_PLAYS # emby_item_id → play count +declare -A ITEM_LAST_PLAY # emby_item_id → ISO date of most recent play + +while IFS='|' read -r item_id play_date; do + [[ -z "$item_id" || "$item_id" == "null" ]] && continue + ITEM_PLAYS["$item_id"]=$(( ${ITEM_PLAYS["$item_id"]:-0} + 1 )) + current="${ITEM_LAST_PLAY["$item_id"]:-}" + if [[ -z "$current" || "$play_date" > "$current" ]]; then + ITEM_LAST_PLAY["$item_id"]="$play_date" + fi +done < <(echo "$ACTIVITY_JSON" | jq -r ' + .Items[] | select(.Type == "playback.stop") | select(.ItemId != null and .ItemId != "") | + [.ItemId, .Date] | join("|") +' 2>/dev/null) + +log "${#ITEM_PLAYS[@]} unique items in activity log" + +declare -A WATCHED_MOVIES # tmdb_id → "title|play_count|last_play_date" + +if [[ "${#ITEM_PLAYS[@]}" -gt 0 ]]; then + # Fetch in batches of 100 — large ID lists exceed GET URL limits + ALL_ITEM_IDS=("${!ITEM_PLAYS[@]}") + BATCH_SIZE=100 + for (( _b=0; _b<${#ALL_ITEM_IDS[@]}; _b+=BATCH_SIZE )); do + BATCH=("${ALL_ITEM_IDS[@]:_b:BATCH_SIZE}") + IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}" + ITEMS_DETAIL=$(_emby_api "Items?Ids=${IDS_CSV}&Fields=ProviderIds,Type&Limit=200") || { + warn "Could not fetch item batch starting at $_b" + continue + } + while IFS='|' read -r item_id item_type tmdb_id title; do + [[ "$item_type" != "Movie" ]] && continue + [[ -z "$tmdb_id" || "$tmdb_id" == "null" ]] && continue + play_count="${ITEM_PLAYS["$item_id"]:-1}" + last_play="${ITEM_LAST_PLAY["$item_id"]:-}" + if [[ -n "${WATCHED_MOVIES["$tmdb_id"]:-}" ]]; then + IFS='|' read -r ex_title ex_plays ex_date <<< "${WATCHED_MOVIES["$tmdb_id"]}" + play_count=$(( ex_plays + play_count )) + [[ "$last_play" > "$ex_date" ]] || last_play="$ex_date" + title="$ex_title" + fi + WATCHED_MOVIES["$tmdb_id"]="${title}|${play_count}|${last_play}" + done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] | + [(.Id | tostring), .Type, (.ProviderIds.Tmdb // ""), .Name] | join("|") + ' 2>/dev/null) + done +fi WATCHED_COUNT=${#WATCHED_MOVIES[@]} log "$WATCHED_COUNT movies watched in the last ${LOOKBACK_DAYS} days with TMDB IDs" @@ -328,23 +364,24 @@ fi # ============================================================================================== # ━━━ Stage 1: Score Watched Movies → Select Seeds ━━━ # ============================================================================================== +# Scoring: recency (0-50) + play frequency across all users (0-50) = max 100 +# No TMDB rating at Stage 1 — CommunityRating is not exposed by Emby's Items API echo "" echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Movies ━━━" S1_SCORED=() # "score|tmdb_id|title" for tmdb_id in "${!WATCHED_MOVIES[@]}"; do - IFS='|' read -r title last_played vote_avg_int vote_count <<< "${WATCHED_MOVIES["$tmdb_id"]}" + IFS='|' read -r title play_count last_play <<< "${WATCHED_MOVIES["$tmdb_id"]}" - last_epoch=$(date -d "$last_played" +%s 2>/dev/null || echo "$TODAY_EPOCH") + last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH") days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 )) recency_s=$(_recency_score "$days_ago") - rating_s=$(_rating_score_s1 "$vote_avg_int") - votes_s=$(_votes_score "$vote_count") - total=$(( recency_s + rating_s + votes_s )) + freq_s=$(_freq_score "$play_count") + total=$(( recency_s + freq_s )) - log " [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | rating: $(echo "scale=1; $vote_avg_int/10" | bc) | votes: $vote_count)" + log " [${total}] $title (TMDB: $tmdb_id | ${days_ago}d ago | plays: $play_count)" S1_SCORED+=("${total}|${tmdb_id}|${title}") done @@ -479,7 +516,7 @@ for rec_id in "${!CANDIDATE_SEEDS[@]}"; do # Hard quality floor — skip before cooldown check to avoid polluting history if (( vote_count < MIN_VOTE_COUNT || vote_avg_int < MIN_RATING )); then (( SKIP_QUALITY++ )) - log " $ICON_SKIP Below quality floor (rating: $(echo "scale=1; $vote_avg_int/10" | bc) | votes: $vote_count): $title" + log " $ICON_SKIP Below quality floor (rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count): $title" continue fi @@ -508,7 +545,7 @@ for rec_id in "${!CANDIDATE_SEEDS[@]}"; do REJECT_LIST+=("$entry") fi - log " [${total}] $title (seeds: $seed_count | rating: $(echo "scale=1; $vote_avg_int/10" | bc) | votes: $vote_count)" + log " [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count)" done # ============================================================================================== @@ -529,7 +566,7 @@ _print_row() { local label="$1" entry="$2" IFS='|' read -r score rec_id title seeds avg_int votes <<< "$entry" local rating_fmt - rating_fmt=$(echo "scale=1; $avg_int/10" | bc 2>/dev/null || echo "?") + rating_fmt=$(_fmt_rating "$avg_int") printf " %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\n" \ "$label" "$score" "$title" "$seeds" "$rating_fmt" "$votes" } diff --git a/master.conf b/master.conf index a2d67e6..a7eb762 100644 --- a/master.conf +++ b/master.conf @@ -951,7 +951,7 @@ LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db" # Radarr discovery shared settings - RADARR_DISCOVERY_THRESHOLD=60 # score to accept candidate (0-100) — lower than Lidarr since movie tastes are broader + RADARR_DISCOVERY_THRESHOLD=50 # score to accept candidate (0-100) — lower than Lidarr since diverse seeds rarely overlap RADARR_DISCOVERY_LOOKBACK_DAYS=30 # Emby watch history window in days (movies rewatched less often) RADARR_DISCOVERY_MAX_SEEDS=5 # max seed movies from Stage 1 RADARR_DISCOVERY_MAX_ADDS=5 # max movies to add per run diff --git a/master_host1.conf b/master_host1.conf index 1114d5b..49533ac 100644 --- a/master_host1.conf +++ b/master_host1.conf @@ -572,6 +572,7 @@ # ━━━ Radarr ━━━ HOST1_RADARR_URL="http://localhost:7878" HOST1_RADARR_API_KEY="d43a3ec6cf1549edb4af0cc63f98b2a9" + HOST1_TMDB_API_KEY="3dac5e2e49b5540472d2eafec4f01260" HOST1_RADARR_MOVIES_ROOT="/mnt/user/Movies" # Note: stand-up_comedy in both Radarr + Sonarr — movie specials and TV specials, one folder