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:
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user