Every script that fetches the full Lidarr/Sonarr/Radarr tracked-library list now goes through arr_get_tracked_data() instead of hitting the API directly -- cache-first when fresh, live fetch as fallback when stale, waits out an active rescan before either. Per-item file data (trackFile/ episodefile/moviefile) stays live-only everywhere, since that's the actual disk-truth these scripts' decisions depend on and was never part of what's cached. Also adds arr_cache_prefill.sh to CRITICAL_MAINTENANCE_SCRIPTS (30min tier) with a short 1min wait ceiling, so the cache stays consistently fresh instead of only refreshing whenever some other script happens to write through. A full cache refresh for all three arrs measured at ~12s total live -- nothing like the multi-hour cost of an actual rescan.
838 lines
35 KiB
Bash
Executable File
838 lines
35 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 host*.conf
|
||
# Free key at: https://www.themoviedb.org/settings/api
|
||
#
|
||
# ==============================================================================================
|
||
# DESIGN PRINCIPLES
|
||
# ==============================================================================================
|
||
#
|
||
# Playback as Intent Signal
|
||
# Recently watched episodes are a stronger signal than what is in the library.
|
||
# User diversity across a series is weighted above a single user binge —
|
||
# broad household interest is a better predictor of a good addition than
|
||
# one person's session.
|
||
#
|
||
# Selective by Design
|
||
# 0–3 adds per run is the target. TV is a larger commitment than movies —
|
||
# a lower MAX_ADDS cap reflects that. Volume is not the goal.
|
||
#
|
||
# Two-Stage Filtering
|
||
# Stage 1 rejects weak seeds before they drive Stage 2. A poorly-watched
|
||
# or niche series produces poor recommendations. Filtering at the seed
|
||
# stage improves the entire output.
|
||
#
|
||
# ==============================================================================================
|
||
# OPERATIONAL SAFEGUARDS
|
||
# ==============================================================================================
|
||
#
|
||
# Root Required
|
||
# acquire_lock and Sonarr API writes require root. Script exits cleanly if not root.
|
||
#
|
||
# Dry-Run Mode
|
||
# --dry-run scores and ranks all candidates but makes no Sonarr API calls and does not
|
||
# write to the history file. Safe to run at any time to preview what would be added.
|
||
#
|
||
# Add-Only
|
||
# Only adds series to Sonarr. Never deletes or modifies existing entries.
|
||
#
|
||
# Cooldown Guard
|
||
# Candidates rejected this run are recorded in the history file and not re-evaluated
|
||
# until SONARR_DISCOVERY_REJECT_COOLDOWN days have elapsed.
|
||
#
|
||
# Monitor Mode
|
||
# SONARR_DISCOVERY_MONITOR_MODE="all" monitors every season on add — correct for shows
|
||
# where you want Sonarr to search back-catalogue. "future" only marks upcoming seasons;
|
||
# use only when you intentionally want to skip existing seasons.
|
||
#
|
||
# ==============================================================================================
|
||
# STATE FILES
|
||
# ==============================================================================================
|
||
#
|
||
# SONARR_DISCOVERY_HISTORY (default: $DATA_DIR/sonarr_discovery_history.db)
|
||
# Tracks added series and rejected candidates with timestamps. Written after every
|
||
# non-dry-run. Enforces reject cooldown and prevents re-adding items added by previous
|
||
# runs. Safe to delete — next run starts fresh with no memory.
|
||
#
|
||
# ==============================================================================================
|
||
# 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: "all" or "future" (default: "all")
|
||
# 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"
|
||
|
||
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 host*.conf"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${SONARR_URL:-}" || -z "${SONARR_API_KEY:-}" ]]; then
|
||
error "SONARR_URL / SONARR_API_KEY not configured — check 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 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}"
|
||
|
||
log "$ICON_GEAR Config: threshold=${THRESHOLD} lookback=${LOOKBACK_DAYS}d max-seeds=${MAX_SEEDS} max-adds=${MAX_ADDS} min-votes=${MIN_VOTE_COUNT} min-rating=${MIN_RATING} reject-cooldown=${REJECT_COOLDOWN}d monitor=${MONITOR_MODE}"
|
||
|
||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no shows will be added to Sonarr"
|
||
|
||
# _fmt_rating() — provided by common.sh
|
||
|
||
# ==============================================================================================
|
||
# ━━━ 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 ───────────────────────────────────────────────────────────────────────────────
|
||
# ==============================================================================================
|
||
|
||
|
||
_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
|
||
}
|
||
|
||
# _rating_score_s2(), _votes_score(), _breadth_score() — provided by common.sh
|
||
|
||
# ==============================================================================================
|
||
# ━━━ 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 ━━━"
|
||
|
||
# Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current
|
||
# every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live
|
||
# fetch when it's stale, and waits out an active rescan before either.
|
||
SONARR_SERIES_JSON=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || { 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"
|
||
|
||
# ==============================================================================================
|
||
# ━━━ 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
|