Fix Radarr discovery: activity log seeding + calibrate threshold

- Stage 1 rewritten to use Emby activity log (playback.stop events)
  with ItemId batch-fetching instead of per-library UserData sort;
  SortBy=DatePlayed requires UserId context and errored server-wide

- Scoring simplified: recency (0-50) + play frequency across all
  users (0-50); CommunityRating is not exposed by Emby Items API
  so TMDB rating scoring moved entirely to Stage 2

- Batch IDs in groups of 100 — 1500+ unique item IDs in a 30-day
  window exceeded GET URL limits on a single request

- Threshold lowered to 50 (from 60); with diverse-genre seeds,
  recommendations rarely appear across multiple seeds so breadth
  score stays at 10/40 — threshold 50 yields ~5 adds per run at
  good quality (rating 7.0+, 1k+ votes)

- HOST1_TMDB_API_KEY moved to sit under Radarr section in
  master_host1.conf where it logically belongs
This commit is contained in:
Gmer4Lfe
2026-05-20 17:05:35 -04:00
parent 4ab3fa26d1
commit 548c662709
3 changed files with 89 additions and 51 deletions
+87 -50
View File
@@ -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"
}