future only monitors seasons not yet aired — shows with all existing seasons would add as unmonitored with nothing to search. all monitors everything so SeriesSearch actually finds episodes to grab.
827 lines
34 KiB
Bash
Executable File
827 lines
34 KiB
Bash
Executable File
#!/bin/bash
|
||
# ==============================================================================================
|
||
# =========================== Playback-Aware Sonarr Discovery ==================================
|
||
# ==============================================================================================
|
||
#
|
||
# PURPOSE
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Two-stage behavior-driven TV show discovery.
|
||
#
|
||
# Stage 1 — Score recently watched series in Emby. The top series become
|
||
# high-quality seeds, weighted by user diversity, recency, and
|
||
# per-user-capped episode volume.
|
||
#
|
||
# Stage 2 — Run TMDB TV recommendations on those seeds. Score the candidates
|
||
# and add the top shows to Sonarr.
|
||
#
|
||
# Goal: 0–3 meaningful Sonarr adds per run, not bulk imports.
|
||
#
|
||
# ==============================================================================================
|
||
# FLOW
|
||
# ==============================================================================================
|
||
#
|
||
# 1. Fetch all Series from Emby SONARR_EMBY_LIBRARIES — build TMDB+TVDB index
|
||
#
|
||
# ── Stage 1 ─────────────────────────────────────────────────────────────────
|
||
# 2. Fetch Emby activity log (last LOOKBACK_DAYS days) — episode play events
|
||
# 3. Batch-fetch episode items → map episode → series → TMDB ID
|
||
# 4. Aggregate per-user episode counts per series
|
||
# 5. Score each series: user diversity + recency + capped volume
|
||
# 6. Take top MAX_SEEDS → discovery seeds
|
||
#
|
||
# ── Stage 2 ─────────────────────────────────────────────────────────────────
|
||
# 7. For each seed, call TMDB TV recommendations → collect candidates
|
||
# 8. Aggregate: breadth (distinct seeds recommending this show)
|
||
# 9. Filter: already in Sonarr, already in Emby, below min votes/rating, cooldown
|
||
# 10. Score candidates: breadth + TMDB rating + vote count
|
||
# 11. Take top MAX_ADDS above threshold
|
||
# 12. Get TVDB ID via TMDB external_ids → Sonarr lookup → add + trigger SeriesSearch
|
||
#
|
||
# ==============================================================================================
|
||
# SCORING MODEL
|
||
# ==============================================================================================
|
||
#
|
||
# Stage 1 — seed selection (max 100)
|
||
# user_diversity_score (0-50) — unique users who watched: 1→10, 2→25, 3→40, 4+→50
|
||
# recency_score (0-30) — days since last episode; 0-7d→30, 8-14d→20, 15-21d→12, 22+→5
|
||
# volume_score (0-20) — sum of min(user_eps, USER_EPISODE_CAP); 1-4→3, 5-12→8, 13-24→14, 25+→20
|
||
#
|
||
# USER_EPISODE_CAP prevents one person binge-watching from dominating seeds.
|
||
# Example: 4 users × 2 eps each beats 1 user × 50 eps (58 vs 18, before recency).
|
||
#
|
||
# Stage 2 — candidate scoring (max 100)
|
||
# breadth_score (0-40) — 3+ seeds→40, 2 seeds→25, 1 seed→10
|
||
# rating_score (0-40) — TMDB vote_average × 10: 80+→40, 75+→32, 70+→25, 65+→18, 60+→12, else→5
|
||
# votes_score (0-20) — TMDB vote_count: 10k+→20, 5k+→15, 1k+→10, 200+→5, else→2
|
||
#
|
||
# Threshold: SONARR_DISCOVERY_THRESHOLD (default 52)
|
||
# Max adds: SONARR_DISCOVERY_MAX_ADDS (default 3) — TV is a larger commitment than movies
|
||
#
|
||
# ==============================================================================================
|
||
# REQUIREMENTS
|
||
# ==============================================================================================
|
||
#
|
||
# TMDB API key — required for Stage 2 recommendations and external_ids lookup
|
||
# Configure HOST*_TMDB_API_KEY in master_host*.conf
|
||
# Free key at: https://www.themoviedb.org/settings/api
|
||
#
|
||
# ==============================================================================================
|
||
# CONFIGURATION (master.conf)
|
||
# ==============================================================================================
|
||
#
|
||
# SONARR_DISCOVERY_THRESHOLD — minimum score to add a candidate (default: 52)
|
||
# SONARR_DISCOVERY_LOOKBACK_DAYS — Emby watch history window in days (default: 14)
|
||
# SONARR_DISCOVERY_MAX_SEEDS — max seed series from Stage 1 (default: 5)
|
||
# SONARR_DISCOVERY_MAX_ADDS — max shows to add per run (default: 3)
|
||
# SONARR_DISCOVERY_MIN_VOTE_COUNT — min TMDB votes for a candidate (default: 50)
|
||
# SONARR_DISCOVERY_MIN_RATING — min TMDB vote_average × 10 (default: 65 = 6.5/10)
|
||
# SONARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a rejected show (default: 60)
|
||
# SONARR_DISCOVERY_USER_EPISODE_CAP — max episodes per user in seed scoring (default: 8)
|
||
# SONARR_DISCOVERY_MONITOR_MODE — Sonarr monitor mode on add (default: "future")
|
||
# SONARR_EMBY_LIBRARIES — Emby library names to draw seeds from
|
||
# SONARR_DISCOVERY_HISTORY — history/state file path
|
||
#
|
||
# ==============================================================================================
|
||
# RUNTIME MODES
|
||
# ==============================================================================================
|
||
#
|
||
# playback_aware_sonarr_discovery.sh — normal run
|
||
# playback_aware_sonarr_discovery.sh --dry-run — score and rank, no Sonarr changes
|
||
# playback_aware_sonarr_discovery.sh --log — verbose output
|
||
# playback_aware_sonarr_discovery.sh --status — show config and exit
|
||
#
|
||
# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf)
|
||
#
|
||
# ==============================================================================================
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
source "$SCRIPT_DIR/../load_config.sh"
|
||
source "$SCRIPT_DIR/../Kernel/decision_engine.sh"
|
||
|
||
parse_args "$@"
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Setup ━━━
|
||
# ==============================================================================================
|
||
if [[ "$EUID" -ne 0 ]]; then
|
||
error "Must be run as root"
|
||
exit 1
|
||
fi
|
||
|
||
acquire_lock
|
||
detect_hosts
|
||
|
||
if ! command -v jq >/dev/null 2>&1; then
|
||
error "jq not installed — required for API JSON parsing"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
|
||
error "EMBY_URL / EMBY_API_KEY not configured — check master_host*.conf"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${SONARR_URL:-}" || -z "${SONARR_API_KEY:-}" ]]; then
|
||
error "SONARR_URL / SONARR_API_KEY not configured — check master_host*.conf"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${TMDB_API_KEY:-}" ]]; then
|
||
error "TMDB_API_KEY not configured — required for discovery"
|
||
error "Get a free key at https://www.themoviedb.org/settings/api"
|
||
error "Configure HOST*_TMDB_API_KEY in master_host*.conf"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ "${#SONARR_EMBY_LIBRARIES[@]}" -eq 0 ]]; then
|
||
error "SONARR_EMBY_LIBRARIES not configured — check master.conf"
|
||
exit 1
|
||
fi
|
||
|
||
THRESHOLD="${SONARR_DISCOVERY_THRESHOLD:-52}"
|
||
LOOKBACK_DAYS="${SONARR_DISCOVERY_LOOKBACK_DAYS:-14}"
|
||
MAX_SEEDS="${SONARR_DISCOVERY_MAX_SEEDS:-5}"
|
||
MAX_ADDS="${SONARR_DISCOVERY_MAX_ADDS:-3}"
|
||
MIN_VOTE_COUNT="${SONARR_DISCOVERY_MIN_VOTE_COUNT:-50}"
|
||
MIN_RATING="${SONARR_DISCOVERY_MIN_RATING:-65}"
|
||
REJECT_COOLDOWN="${SONARR_DISCOVERY_REJECT_COOLDOWN:-60}"
|
||
USER_EPISODE_CAP="${SONARR_DISCOVERY_USER_EPISODE_CAP:-8}"
|
||
MONITOR_MODE="${SONARR_DISCOVERY_MONITOR_MODE:-all}"
|
||
HISTORY_FILE="${SONARR_DISCOVERY_HISTORY:-${DATA_DIR}/sonarr_discovery_history.db}"
|
||
|
||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr"
|
||
|
||
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Status ━━━
|
||
# ==============================================================================================
|
||
if [[ "$SHOW_STATUS" == true ]]; then
|
||
echo ""
|
||
echo "━━━━━ $ICON_SUMMARY SONARR DISCOVERY STATUS ━━━━━"
|
||
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
||
echo "$ICON_SYNC Emby: ${EMBY_URL}"
|
||
echo "$ICON_SYNC Sonarr: ${SONARR_URL}"
|
||
echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100"
|
||
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} ($(_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 User ep cap: ${USER_EPISODE_CAP} episodes"
|
||
echo "$ICON_GEAR Monitor mode: ${MONITOR_MODE}"
|
||
echo "$ICON_GEAR Seed libs: ${SONARR_EMBY_LIBRARIES[*]}"
|
||
echo "$ICON_GEAR History file: ${HISTORY_FILE}"
|
||
echo "$ICON_GEAR TMDB: configured"
|
||
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
||
exit 0
|
||
fi
|
||
|
||
# ==============================================================================================
|
||
# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
_emby_api() {
|
||
local response http_code body
|
||
response=$(curl -sf --max-time 30 \
|
||
-H "X-Emby-Token: $EMBY_API_KEY" \
|
||
-w "\n%{http_code}" \
|
||
"${EMBY_URL}/${1}" 2>/dev/null)
|
||
http_code=$(echo "$response" | tail -1)
|
||
body=$(echo "$response" | head -n -1)
|
||
[[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $1"; return 1; }
|
||
echo "$body"
|
||
}
|
||
|
||
_sonarr_get() {
|
||
curl -sf --max-time 30 \
|
||
-H "X-Api-Key: $SONARR_API_KEY" \
|
||
"${SONARR_URL}/api/v3/${1}" 2>/dev/null
|
||
}
|
||
|
||
_sonarr_lookup() {
|
||
curl -sf --max-time 20 --get \
|
||
--data-urlencode "term=$1" \
|
||
-H "X-Api-Key: $SONARR_API_KEY" \
|
||
"${SONARR_URL}/api/v3/series/lookup" 2>/dev/null
|
||
}
|
||
|
||
_sonarr_post() {
|
||
curl -sf --max-time 20 -X POST \
|
||
-H "X-Api-Key: $SONARR_API_KEY" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$1" \
|
||
"${SONARR_URL}/api/v3/series" 2>/dev/null
|
||
}
|
||
|
||
_sonarr_command() {
|
||
curl -sf --max-time 20 -X POST \
|
||
-H "X-Api-Key: $SONARR_API_KEY" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$1" \
|
||
"${SONARR_URL}/api/v3/command" 2>/dev/null
|
||
}
|
||
|
||
_tmdb_tv_recommendations() {
|
||
curl -sf --max-time 15 \
|
||
"https://api.themoviedb.org/3/tv/${1}/recommendations?api_key=${TMDB_API_KEY}&language=en-US&page=1" \
|
||
2>/dev/null
|
||
}
|
||
|
||
_tmdb_external_ids() {
|
||
curl -sf --max-time 15 \
|
||
"https://api.themoviedb.org/3/tv/${1}/external_ids?api_key=${TMDB_API_KEY}" \
|
||
2>/dev/null
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ── SCORING HELPERS ───────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
# Stage 1: unique user count score (0-50) — primary seed driver
|
||
_diversity_score() {
|
||
local n="$1"
|
||
if (( n >= 4 )); then echo 50
|
||
elif (( n == 3 )); then echo 40
|
||
elif (( n == 2 )); then echo 25
|
||
else echo 10
|
||
fi
|
||
}
|
||
|
||
# Stage 1: recency of most recent episode watched (0-30)
|
||
_recency_score() {
|
||
local days="$1"
|
||
if (( days <= 7 )); then echo 30
|
||
elif (( days <= 14 )); then echo 20
|
||
elif (( days <= 21 )); then echo 12
|
||
elif (( days <= 30 )); then echo 5
|
||
else echo 0
|
||
fi
|
||
}
|
||
|
||
# Stage 1: per-user-capped episode volume score (0-20)
|
||
_volume_score() {
|
||
local v="$1"
|
||
if (( v >= 25 )); then echo 20
|
||
elif (( v >= 13 )); then echo 14
|
||
elif (( v >= 5 )); then echo 8
|
||
elif (( v >= 1 )); then echo 3
|
||
else echo 0
|
||
fi
|
||
}
|
||
|
||
# Stage 2: TMDB vote_average × 10 (0-40)
|
||
_rating_score_s2() {
|
||
local v="$1"
|
||
if (( v >= 80 )); then echo 40
|
||
elif (( v >= 75 )); then echo 32
|
||
elif (( v >= 70 )); then echo 25
|
||
elif (( v >= 65 )); then echo 18
|
||
elif (( v >= 60 )); then echo 12
|
||
else echo 5
|
||
fi
|
||
}
|
||
|
||
# Stage 2: vote count (0-20)
|
||
_votes_score() {
|
||
local c="$1"
|
||
if (( c >= 10000 )); then echo 20
|
||
elif (( c >= 5000 )); then echo 15
|
||
elif (( c >= 1000 )); then echo 10
|
||
elif (( c >= 200 )); then echo 5
|
||
else echo 2
|
||
fi
|
||
}
|
||
|
||
# Stage 2: seed breadth (0-40)
|
||
_breadth_score() {
|
||
local seeds="$1"
|
||
if (( seeds >= 3 )); then echo 40
|
||
elif (( seeds == 2 )); then echo 25
|
||
else echo 10
|
||
fi
|
||
}
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Fetch Emby Series Library ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━━━ $ICON_SUMMARY Sonarr Discovery — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━"
|
||
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) | lookback: ${LOOKBACK_DAYS}d | threshold: ${THRESHOLD}/100 | max: ${MAX_ADDS}"
|
||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added"
|
||
|
||
echo ""
|
||
echo "━━━ $ICON_SYNC Emby Series Library ━━━"
|
||
|
||
LIBRARIES_JSON=$(_emby_api "Library/VirtualFolders") || { error "Could not fetch Emby libraries"; exit 1; }
|
||
CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ')
|
||
TODAY_EPOCH=$(date +%s)
|
||
TODAY=$(date +%Y-%m-%d)
|
||
|
||
# EMBY_SERIES_BY_ID[emby_item_id] = tmdb_id — used to map episode.SeriesId → series TMDB ID
|
||
# EMBY_SERIES_NAME[tmdb_id] = name — used to display seed names in Stage 1
|
||
# EMBY_TVDB_IDS[tvdb_id] = 1 — used to filter already-owned shows in Stage 2
|
||
# EMBY_TMDB_IDS[tmdb_id] = 1 — used to filter already-owned shows in Stage 2
|
||
declare -A EMBY_SERIES_BY_ID
|
||
declare -A EMBY_SERIES_NAME
|
||
declare -A EMBY_TVDB_IDS
|
||
declare -A EMBY_TMDB_IDS
|
||
|
||
for lib_name in "${SONARR_EMBY_LIBRARIES[@]}"; do
|
||
lib_id=$(echo "$LIBRARIES_JSON" | jq -r --arg n "$lib_name" '.[] | select(.Name == $n) | .ItemId' 2>/dev/null)
|
||
if [[ -z "$lib_id" ]]; then
|
||
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=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || {
|
||
warn "Could not fetch series from library: $lib_name"
|
||
continue
|
||
}
|
||
while IFS='|' read -r emby_id tmdb_id tvdb_id name; do
|
||
[[ -z "$emby_id" || "$emby_id" == "null" ]] && continue
|
||
[[ -n "$tmdb_id" && "$tmdb_id" != "null" ]] && {
|
||
EMBY_SERIES_BY_ID["$emby_id"]="$tmdb_id"
|
||
EMBY_TMDB_IDS["$tmdb_id"]=1
|
||
[[ -n "$name" && "$name" != "null" ]] && EMBY_SERIES_NAME["$tmdb_id"]="$name"
|
||
}
|
||
[[ -n "$tvdb_id" && "$tvdb_id" != "null" ]] && EMBY_TVDB_IDS["$tvdb_id"]=1
|
||
done < <(echo "$LIB_JSON" | jq -r '.Items[] |
|
||
[.Id, (.ProviderIds.Tmdb // ""), (.ProviderIds.Tvdb // ""), .Name] | join("|")
|
||
' 2>/dev/null)
|
||
done
|
||
|
||
log "${#EMBY_SERIES_BY_ID[@]} series in Emby TMDB index"
|
||
|
||
if [[ "${#EMBY_SERIES_BY_ID[@]}" -eq 0 ]]; then
|
||
warn "No series found in Emby libraries — nothing to seed from"
|
||
exit 0
|
||
fi
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Fetch Emby Activity Log — Episode Plays ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_SYNC Emby Watch History ━━━"
|
||
|
||
# Activity log entries: each playback.stop has ItemId (episode), UserId, Date.
|
||
# Track per-user episode plays so we can cap any single user's influence on seed scoring.
|
||
declare -A ITEM_USER_PLAYS # "episode_item_id|user_id" → play count
|
||
declare -A ITEM_LAST_PLAY # episode_item_id → ISO date of most recent play
|
||
|
||
ACTIVITY_JSON=$(_emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || {
|
||
error "Could not fetch Emby activity log"
|
||
exit 1
|
||
}
|
||
|
||
while IFS='|' read -r item_id user_id play_date; do
|
||
[[ -z "$item_id" || "$item_id" == "null" ]] && continue
|
||
[[ -z "$user_id" || "$user_id" == "null" ]] && user_id="unknown"
|
||
key="${item_id}|${user_id}"
|
||
ITEM_USER_PLAYS["$key"]=$(( ${ITEM_USER_PLAYS["$key"]:-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, (.UserId // "unknown"), .Date] | join("|")
|
||
' 2>/dev/null)
|
||
|
||
# Deduplicate episode IDs for batch fetch
|
||
declare -A _UNIQUE_IDS
|
||
for key in "${!ITEM_USER_PLAYS[@]}"; do
|
||
episode_id="${key%%|*}"
|
||
_UNIQUE_IDS["$episode_id"]=1
|
||
done
|
||
ALL_EPISODE_IDS=("${!_UNIQUE_IDS[@]}")
|
||
unset _UNIQUE_IDS
|
||
|
||
log "${#ALL_EPISODE_IDS[@]} unique episode items in activity log"
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Map Episodes → Series via Batch Fetch ━━━
|
||
# ==============================================================================================
|
||
|
||
# EPISODE_TO_SERIES[episode_item_id] = series_tmdb_id
|
||
declare -A EPISODE_TO_SERIES
|
||
|
||
if [[ "${#ALL_EPISODE_IDS[@]}" -gt 0 ]]; then
|
||
BATCH_SIZE=100
|
||
for (( _b=0; _b<${#ALL_EPISODE_IDS[@]}; _b+=BATCH_SIZE )); do
|
||
BATCH=("${ALL_EPISODE_IDS[@]:_b:BATCH_SIZE}")
|
||
IDS_CSV=$(printf '%s,' "${BATCH[@]}"); IDS_CSV="${IDS_CSV%,}"
|
||
ITEMS_DETAIL=$(_emby_api "Items?Ids=${IDS_CSV}&Fields=SeriesId,Type&Limit=200") || {
|
||
warn "Could not fetch episode batch starting at $_b"
|
||
continue
|
||
}
|
||
while IFS='|' read -r item_id item_type series_emby_id; do
|
||
[[ "$item_type" != "Episode" ]] && continue
|
||
[[ -z "$series_emby_id" || "$series_emby_id" == "null" ]] && continue
|
||
series_tmdb="${EMBY_SERIES_BY_ID["$series_emby_id"]:-}"
|
||
[[ -z "$series_tmdb" ]] && continue
|
||
EPISODE_TO_SERIES["$item_id"]="$series_tmdb"
|
||
done < <(echo "$ITEMS_DETAIL" | jq -r '.Items[] |
|
||
[(.Id | tostring), .Type, (.SeriesId // "")] | join("|")
|
||
' 2>/dev/null)
|
||
done
|
||
fi
|
||
|
||
# Aggregate per-user episode counts per series
|
||
# SERIES_USER_PLAYS["series_tmdb_id|user_id"] = total episodes played by that user
|
||
# One key per (series, user) pair — safe to count for diversity and cap for volume
|
||
declare -A SERIES_USER_PLAYS
|
||
declare -A SERIES_LAST_PLAY
|
||
|
||
for key in "${!ITEM_USER_PLAYS[@]}"; do
|
||
episode_id="${key%%|*}"
|
||
user_id="${key#*|}"
|
||
series_tmdb="${EPISODE_TO_SERIES["$episode_id"]:-}"
|
||
[[ -z "$series_tmdb" ]] && continue
|
||
plays="${ITEM_USER_PLAYS[$key]}"
|
||
skey="${series_tmdb}|${user_id}"
|
||
SERIES_USER_PLAYS["$skey"]=$(( ${SERIES_USER_PLAYS["$skey"]:-0} + plays ))
|
||
play_date="${ITEM_LAST_PLAY["$episode_id"]:-}"
|
||
current="${SERIES_LAST_PLAY["$series_tmdb"]:-}"
|
||
if [[ -n "$play_date" && ( -z "$current" || "$play_date" > "$current" ) ]]; then
|
||
SERIES_LAST_PLAY["$series_tmdb"]="$play_date"
|
||
fi
|
||
done
|
||
|
||
# Pre-aggregate stats per series for scoring
|
||
declare -A SERIES_UNIQUE_USERS # series_tmdb → unique user count
|
||
declare -A SERIES_CAPPED_VOLUME # series_tmdb → sum of min(user_episodes, cap)
|
||
|
||
for key in "${!SERIES_USER_PLAYS[@]}"; do
|
||
series_tmdb="${key%%|*}"
|
||
plays="${SERIES_USER_PLAYS[$key]}"
|
||
capped=$(( plays > USER_EPISODE_CAP ? USER_EPISODE_CAP : plays ))
|
||
SERIES_UNIQUE_USERS["$series_tmdb"]=$(( ${SERIES_UNIQUE_USERS["$series_tmdb"]:-0} + 1 ))
|
||
SERIES_CAPPED_VOLUME["$series_tmdb"]=$(( ${SERIES_CAPPED_VOLUME["$series_tmdb"]:-0} + capped ))
|
||
done
|
||
|
||
SERIES_COUNT=${#SERIES_UNIQUE_USERS[@]}
|
||
log "$SERIES_COUNT series with recent episode activity"
|
||
|
||
if [[ "$SERIES_COUNT" -eq 0 ]]; then
|
||
warn "No recently watched series found — nothing to seed from"
|
||
exit 0
|
||
fi
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Stage 1: Score Watched Series → Select Seeds ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_GEAR Stage 1: Scoring Watched Series ━━━"
|
||
|
||
S1_SCORED=() # "score|tmdb_id|title"
|
||
|
||
for series_tmdb in "${!SERIES_UNIQUE_USERS[@]}"; do
|
||
n_users="${SERIES_UNIQUE_USERS[$series_tmdb]}"
|
||
capped_vol="${SERIES_CAPPED_VOLUME[$series_tmdb]:-0}"
|
||
last_play="${SERIES_LAST_PLAY[$series_tmdb]:-}"
|
||
series_name="${EMBY_SERIES_NAME[$series_tmdb]:-TMDB:${series_tmdb}}"
|
||
|
||
last_epoch=$(date -d "$last_play" +%s 2>/dev/null || echo "$TODAY_EPOCH")
|
||
days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
|
||
|
||
diversity_s=$(_diversity_score "$n_users")
|
||
recency_s=$(_recency_score "$days_ago")
|
||
volume_s=$(_volume_score "$capped_vol")
|
||
total=$(( diversity_s + recency_s + volume_s ))
|
||
|
||
log " [${total}] $series_name | users: $n_users | vol(capped): $capped_vol | ${days_ago}d ago"
|
||
S1_SCORED+=("${total}|${series_tmdb}|${series_name}")
|
||
done
|
||
|
||
IFS=$'\n' S1_SORTED=($(printf '%s\n' "${S1_SCORED[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
|
||
|
||
echo ""
|
||
echo "━━━ $ICON_SUMMARY Stage 1 Results (top ${MAX_SEEDS} seeds) ━━━"
|
||
|
||
SEEDS_TMDB=()
|
||
|
||
for _entry in "${S1_SORTED[@]}"; do
|
||
(( ${#SEEDS_TMDB[@]} >= MAX_SEEDS )) && break
|
||
_score="${_entry%%|*}"
|
||
_rest="${_entry#*|}"
|
||
_tmdb="${_rest%%|*}"
|
||
_name="${_rest#*|}"
|
||
SEEDS_TMDB+=("$_tmdb")
|
||
n_users="${SERIES_UNIQUE_USERS[$_tmdb]:-0}"
|
||
capped_vol="${SERIES_CAPPED_VOLUME[$_tmdb]:-0}"
|
||
printf " Seed [%3s] %-40s | users: %s | vol: %s\n" "$_score" "$_name" "$n_users" "$capped_vol"
|
||
done
|
||
|
||
if [[ "${#SEEDS_TMDB[@]}" -eq 0 ]]; then
|
||
warn "No series qualify as seeds — nothing to discover from"
|
||
exit 0
|
||
fi
|
||
|
||
log "${#SEEDS_TMDB[@]} seed(s) selected"
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Stage 2: TMDB TV Recommendations ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_SYNC Stage 2: TMDB Recommendations ━━━"
|
||
|
||
declare -A CANDIDATE_SEEDS # tmdb_id → seed count
|
||
declare -A CANDIDATE_TITLE # tmdb_id → show name
|
||
declare -A CANDIDATE_RATING # tmdb_id → vote_avg_int
|
||
declare -A CANDIDATE_VOTES # tmdb_id → vote_count
|
||
|
||
for seed_tmdb in "${SEEDS_TMDB[@]}"; do
|
||
log " Seed TMDB: $seed_tmdb"
|
||
|
||
RECS_JSON=$(_tmdb_tv_recommendations "$seed_tmdb")
|
||
if [[ -z "$RECS_JSON" ]] || echo "$RECS_JSON" | jq -e '.results == [] or .results == null' >/dev/null 2>&1; then
|
||
warn " No TMDB recommendations for TMDB:$seed_tmdb"
|
||
continue
|
||
fi
|
||
|
||
while IFS='|' read -r rec_id rec_name rec_avg rec_votes; do
|
||
[[ -z "$rec_id" || "$rec_id" == "null" ]] && continue
|
||
[[ "$rec_id" == "$seed_tmdb" ]] && continue
|
||
|
||
vote_avg_int=$(echo "$rec_avg" | awk '{printf "%d", $1 * 10 + 0.5}' 2>/dev/null)
|
||
vote_avg_int=${vote_avg_int:-0}
|
||
rec_votes=${rec_votes:-0}
|
||
|
||
CANDIDATE_SEEDS["$rec_id"]=$(( ${CANDIDATE_SEEDS["$rec_id"]:-0} + 1 ))
|
||
CANDIDATE_TITLE["$rec_id"]="$rec_name"
|
||
CANDIDATE_RATING["$rec_id"]="$vote_avg_int"
|
||
CANDIDATE_VOTES["$rec_id"]="$rec_votes"
|
||
done < <(echo "$RECS_JSON" | jq -r '.results[] |
|
||
[
|
||
(.id | tostring),
|
||
.name,
|
||
(.vote_average | tostring),
|
||
(.vote_count | tostring)
|
||
] | join("|")' 2>/dev/null)
|
||
done
|
||
|
||
CANDIDATE_COUNT=${#CANDIDATE_SEEDS[@]}
|
||
log "$CANDIDATE_COUNT discovery candidates from TMDB"
|
||
|
||
if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then
|
||
warn "No candidates returned from TMDB — check API key"
|
||
exit 0
|
||
fi
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Fetch Existing Sonarr Library ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_SYNC Existing Libraries ━━━"
|
||
|
||
SONARR_SERIES_JSON=$(_sonarr_get "series") || { error "Could not fetch Sonarr library"; exit 1; }
|
||
|
||
declare -A SONARR_TVDB # tvdb_id → 1
|
||
declare -A SONARR_TMDB # tmdb_id → 1 (Sonarr v4 exposes tmdbId)
|
||
|
||
while IFS='|' read -r tvdb_id tmdb_id; do
|
||
[[ -n "$tvdb_id" && "$tvdb_id" != "0" ]] && SONARR_TVDB["$tvdb_id"]=1
|
||
[[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && SONARR_TMDB["$tmdb_id"]=1
|
||
done < <(echo "$SONARR_SERIES_JSON" | jq -r '.[] |
|
||
[(.tvdbId // 0 | tostring), (.tmdbId // 0 | tostring)] | join("|")
|
||
' 2>/dev/null)
|
||
|
||
log "${#SONARR_TVDB[@]} series in Sonarr | ${#EMBY_TMDB_IDS[@]} series in Emby"
|
||
|
||
_in_sonarr() { [[ "${SONARR_TVDB["$1"]+x}" || "${SONARR_TMDB["$2"]+x}" ]]; }
|
||
_in_emby() { [[ "${EMBY_TMDB_IDS["$1"]+x}" || "${EMBY_TVDB_IDS["$2"]+x}" ]]; }
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Score Stage 2 Candidates ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━"
|
||
|
||
ACCEPT_LIST=()
|
||
REJECT_LIST=()
|
||
ALREADY_KNOWN=0
|
||
SKIP_COOLDOWN=0
|
||
SKIP_QUALITY=0
|
||
|
||
for rec_tmdb in "${!CANDIDATE_SEEDS[@]}"; do
|
||
seed_count="${CANDIDATE_SEEDS["$rec_tmdb"]}"
|
||
title="${CANDIDATE_TITLE["$rec_tmdb"]}"
|
||
vote_avg_int="${CANDIDATE_RATING["$rec_tmdb"]}"
|
||
vote_count="${CANDIDATE_VOTES["$rec_tmdb"]}"
|
||
|
||
# Check Sonarr by TMDB (tvdb unknown at this point — we resolve it only at add time)
|
||
if [[ "${SONARR_TMDB["$rec_tmdb"]+x}" ]]; then
|
||
(( ALREADY_KNOWN++ ))
|
||
log " $ICON_SKIP In Sonarr: $title"
|
||
continue
|
||
fi
|
||
|
||
if [[ "${EMBY_TMDB_IDS["$rec_tmdb"]+x}" ]]; then
|
||
(( ALREADY_KNOWN++ ))
|
||
log " $ICON_SKIP In Emby: $title"
|
||
continue
|
||
fi
|
||
|
||
# Hard quality floor — skip before cooldown 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: $(_fmt_rating "$vote_avg_int") | votes: $vote_count): $title"
|
||
continue
|
||
fi
|
||
|
||
if [[ -f "$HISTORY_FILE" ]]; then
|
||
last_rejection=$(grep -i "^REJECT|${rec_tmdb}|" "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d'|' -f3)
|
||
if [[ -n "$last_rejection" ]]; then
|
||
last_epoch=$(date -d "$last_rejection" +%s 2>/dev/null || echo 0)
|
||
days_since=$(( (TODAY_EPOCH - last_epoch) / 86400 ))
|
||
if (( days_since < REJECT_COOLDOWN )); then
|
||
(( SKIP_COOLDOWN++ ))
|
||
log " $ICON_SKIP Cooldown (rejected ${days_since}d ago): $title"
|
||
continue
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
breadth_s=$(_breadth_score "$seed_count")
|
||
rating_s=$(_rating_score_s2 "$vote_avg_int")
|
||
votes_s=$(_votes_score "$vote_count")
|
||
total=$(( breadth_s + rating_s + votes_s ))
|
||
|
||
entry="${total}|${rec_tmdb}|${title}|${seed_count}|${vote_avg_int}|${vote_count}"
|
||
if (( total >= THRESHOLD )); then
|
||
ACCEPT_LIST+=("$entry")
|
||
else
|
||
REJECT_LIST+=("$entry")
|
||
fi
|
||
|
||
log " [${total}] $title (seeds: $seed_count | rating: $(_fmt_rating "$vote_avg_int") | votes: $vote_count)"
|
||
done
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Results ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_SUMMARY Stage 2 Results (threshold: ${THRESHOLD}, max: ${MAX_ADDS}) ━━━"
|
||
|
||
IFS=$'\n' _ALL_ACCEPTS=($(printf '%s\n' "${ACCEPT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
|
||
IFS=$'\n' SORTED_REJECTS=($(printf '%s\n' "${REJECT_LIST[@]}" | sort -t'|' -k1 -rn 2>/dev/null))
|
||
|
||
SORTED_ACCEPTS=("${_ALL_ACCEPTS[@]:0:$MAX_ADDS}")
|
||
for (( _i=MAX_ADDS; _i<${#_ALL_ACCEPTS[@]}; _i++ )); do
|
||
SORTED_REJECTS+=("${_ALL_ACCEPTS[$_i]}")
|
||
done
|
||
|
||
_print_row() {
|
||
local label="$1" entry="$2"
|
||
IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry"
|
||
local rating_fmt
|
||
rating_fmt=$(_fmt_rating "$avg_int")
|
||
printf " %-8s [%3s] %-45s seeds: %s | rating: %s | votes: %s\n" \
|
||
"$label" "$score" "$title" "$seeds" "$rating_fmt" "$votes"
|
||
}
|
||
|
||
for entry in "${SORTED_ACCEPTS[@]}"; do [[ -n "$entry" ]] && _print_row "ACCEPT" "$entry"; done
|
||
for entry in "${SORTED_REJECTS[@]}"; do [[ -n "$entry" ]] && _print_row "REJECT" "$entry"; done
|
||
|
||
echo ""
|
||
echo " Already in library: $ALREADY_KNOWN | Below quality floor: $SKIP_QUALITY | Cooldown: $SKIP_COOLDOWN"
|
||
echo " ACCEPT: ${#SORTED_ACCEPTS[@]} | REJECT: ${#SORTED_REJECTS[@]}"
|
||
|
||
if [[ "$DRY_RUN" == true ]]; then
|
||
warn "DRY RUN complete — run without --dry-run to add accepted shows"
|
||
exit 0
|
||
fi
|
||
|
||
if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then
|
||
log "No shows above threshold — nothing to add"
|
||
for entry in "${SORTED_REJECTS[@]}"; do
|
||
[[ -z "$entry" ]] && continue
|
||
IFS='|' read -r score rec_tmdb title _ <<< "$entry"
|
||
echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
done
|
||
exit 0
|
||
fi
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Add to Sonarr ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━ $ICON_SYNC Adding to Sonarr ━━━"
|
||
|
||
SONARR_ROOT=$(_sonarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
|
||
if [[ -z "$SONARR_ROOT" ]]; then error "Could not determine Sonarr root folder"; exit 1; fi
|
||
|
||
QUALITY_ID=$(_sonarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null)
|
||
log "Root folder: $SONARR_ROOT | Quality profile: $QUALITY_ID | Monitor: $MONITOR_MODE"
|
||
|
||
ADDED=0
|
||
FAILED=0
|
||
|
||
for entry in "${SORTED_ACCEPTS[@]}"; do
|
||
[[ -z "$entry" ]] && continue
|
||
IFS='|' read -r score rec_tmdb title seeds avg_int votes <<< "$entry"
|
||
|
||
# Resolve TVDB ID via TMDB external_ids — Sonarr lookup requires tvdb: term
|
||
EXT_JSON=$(_tmdb_external_ids "$rec_tmdb")
|
||
tvdb_id=$(echo "$EXT_JSON" | jq -r '.tvdb_id // empty' 2>/dev/null)
|
||
|
||
if [[ -z "$tvdb_id" ]]; then
|
||
warn " $ICON_WARN No TVDB ID from TMDB for: $title (TMDB: $rec_tmdb)"
|
||
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
(( FAILED++ ))
|
||
continue
|
||
fi
|
||
|
||
# Check if already in Sonarr by TVDB (may have been added since we loaded the library)
|
||
if [[ "${SONARR_TVDB["$tvdb_id"]+x}" ]]; then
|
||
log " $ICON_SKIP Already in Sonarr (TVDB $tvdb_id): $title"
|
||
(( ALREADY_KNOWN++ ))
|
||
continue
|
||
fi
|
||
|
||
# Check if already in Emby by TVDB
|
||
if [[ "${EMBY_TVDB_IDS["$tvdb_id"]+x}" ]]; then
|
||
log " $ICON_SKIP Already in Emby (TVDB $tvdb_id): $title"
|
||
(( ALREADY_KNOWN++ ))
|
||
continue
|
||
fi
|
||
|
||
LOOKUP=$(_sonarr_lookup "tvdb:$tvdb_id")
|
||
|
||
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
|
||
warn " $ICON_WARN No match in Sonarr lookup: $title (TVDB: $tvdb_id)"
|
||
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
(( FAILED++ ))
|
||
continue
|
||
fi
|
||
|
||
SERIES_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
|
||
SONARR_TITLE=$(echo "$SERIES_DATA" | jq -r '.title // ""' 2>/dev/null)
|
||
|
||
if [[ -z "$SONARR_TITLE" ]]; then
|
||
warn " $ICON_WARN Empty result for: $title"
|
||
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
(( FAILED++ ))
|
||
continue
|
||
fi
|
||
|
||
PAYLOAD=$(echo "$SERIES_DATA" | jq \
|
||
--arg root "$SONARR_ROOT" \
|
||
--argjson qid "$QUALITY_ID" \
|
||
--arg mon "$MONITOR_MODE" \
|
||
'. + {
|
||
rootFolderPath: $root,
|
||
qualityProfileId: $qid,
|
||
monitored: true,
|
||
seasonFolder: true,
|
||
addOptions: {
|
||
monitor: $mon,
|
||
searchForMissingEpisodes: false,
|
||
searchForCutoffUnmetEpisodes: false
|
||
}
|
||
}' 2>/dev/null)
|
||
|
||
RESULT=$(_sonarr_post "$PAYLOAD")
|
||
|
||
if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then
|
||
SERIES_ID=$(echo "$RESULT" | jq -r '.id')
|
||
_sonarr_command "{\"name\":\"SeriesSearch\",\"seriesId\":${SERIES_ID}}" >/dev/null
|
||
log " $ICON_DONE Added: $SONARR_TITLE (score: $score | seeds: $seeds | TVDB: $tvdb_id)"
|
||
echo "ACCEPT|${rec_tmdb}|${TODAY}|${SONARR_TITLE}" >> "$HISTORY_FILE" 2>/dev/null
|
||
(( ADDED++ ))
|
||
else
|
||
warn " $ICON_WARN Failed to add: $title"
|
||
log " $(echo "$RESULT" | head -c 200)"
|
||
echo "FAIL|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
(( FAILED++ ))
|
||
fi
|
||
done
|
||
|
||
for entry in "${SORTED_REJECTS[@]}"; do
|
||
[[ -z "$entry" ]] && continue
|
||
IFS='|' read -r score rec_tmdb title _ <<< "$entry"
|
||
echo "REJECT|${rec_tmdb}|${TODAY}|${title}" >> "$HISTORY_FILE" 2>/dev/null
|
||
done
|
||
|
||
# ==============================================================================================
|
||
# ━━━ Summary ━━━
|
||
# ==============================================================================================
|
||
echo ""
|
||
echo "━━━━━ $ICON_SUMMARY DISCOVERY COMPLETE ━━━━━"
|
||
echo " $ICON_DONE Added: $ADDED"
|
||
[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED"
|
||
echo " $ICON_SKIP Rejected: ${#SORTED_REJECTS[@]}"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
|
||
[[ "$ADDED" -gt 0 ]] && notify \
|
||
"$ADDED show(s) added to Sonarr via discovery on $(hostname)" \
|
||
"Sonarr Discovery" "normal"
|
||
|
||
exit 0
|