From 7ccb7e0e672f0edf1cc5fbbe903602fbc6d09c26 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Tue, 19 May 2026 23:34:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20two-stage=20Lidarr=20discovery=20+=20Em?= =?UTF-8?q?by=E2=86=92arr=20sync=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lidarr Discovery (playback_aware_lidarr_discovery.sh): - Complete rewrite to two-stage pipeline: Stage 1 scores weekly Emby plays → top seeds; Stage 2 runs Last.fm getSimilar on seeds → scores candidates → adds top 0-5 to Lidarr - Per-user influence cap (35%) prevents single listener dominating discovery - Requires 2+ seeds to accept a candidate (single-seed skipped) - Last.fm similarity limit 10 for tighter, higher-quality candidates - 30-day reject cooldown history; MAX_ADDS=5 cap enforced - Root folder fetched from Lidarr API at runtime (no config path) - Added to WEEKLY_MAINTENANCE_SCRIPTS (uncommented) Emby→arr sync tools (Tools/): - emby_to_lidarr_sync.sh: finds Emby music artists not in Lidarr, adds them - Dirty tag filter: comma-list, feat./ft., &, vs, " - " patterns skipped - Root folder fetched from Lidarr API at runtime - emby_to_sonarr_sync.sh: finds Emby series not in Sonarr, adds them - TVDB ID matching with title fallback - emby_to_radarr_sync.sh: finds Emby movies not in Radarr, adds them - TMDB ID matching with title fallback - All three: searchForMissing*: false (monitoring only, no searches triggered) - All three documented in Tools/Manual-Tools.md --- Media/playback_aware_lidarr_discovery.sh | 739 +++++++++++++++++++++-- Tools/Manual-Tools.md | 101 ++++ Tools/emby_to_lidarr_sync.sh | 291 +++++++++ Tools/emby_to_radarr_sync.sh | 282 +++++++++ Tools/emby_to_sonarr_sync.sh | 284 +++++++++ master.conf | 11 +- 6 files changed, 1641 insertions(+), 67 deletions(-) mode change 100644 => 100755 Media/playback_aware_lidarr_discovery.sh create mode 100755 Tools/emby_to_lidarr_sync.sh create mode 100644 Tools/emby_to_radarr_sync.sh create mode 100644 Tools/emby_to_sonarr_sync.sh diff --git a/Media/playback_aware_lidarr_discovery.sh b/Media/playback_aware_lidarr_discovery.sh old mode 100644 new mode 100755 index 86ad335..06f301c --- a/Media/playback_aware_lidarr_discovery.sh +++ b/Media/playback_aware_lidarr_discovery.sh @@ -5,102 +5,709 @@ # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── -# Behavior-driven music discovery scoring prototype. Consumes -# Kernel/decision_engine.sh to score artist/album candidates for acquisition -# using family-aware and playback-weighted logic. +# Two-stage behavior-driven music discovery. # -# WIP — not yet connected to a live data source or scheduled. Discovery logic -# is intentionally selective: higher strictness, stronger quality bias, lower -# tolerance for trend-chasing, longer behavioral memory than TV/movies. +# Stage 1 — Score what you actually played this week. The top artists become +# high-quality seeds, not just anything that hit the minimum play count. +# +# Stage 2 — Run Last.fm artist.getSimilar on those seeds. Score the recommendations +# and add the top artists to Lidarr. +# +# Goal: 0–5 meaningful Lidarr adds per week, not bulk imports. +# +# ============================================================================================== +# FLOW +# ============================================================================================== +# +# 1. Fetch play completions from Emby activity log (last LOOKBACK_DAYS days) +# 2. Aggregate by artist — apply per-user influence cap to play weights +# +# ── Stage 1 ───────────────────────────────────────────────────────────────── +# 3. Score each played artist: user signal + recency + Last.fm popularity/quality +# 4. Take top MAX_ADDS by score → discovery seeds +# +# ── Stage 2 ───────────────────────────────────────────────────────────────── +# 5. For each seed, call Last.fm artist.getSimilar → collect candidates +# 6. Aggregate: affinity (weighted similarity × seed score) + breadth (distinct seeds) +# 7. Filter: already in Lidarr, already in Emby, placeholder artists, cooldown +# 8. Score candidates: affinity + breadth + popularity + quality +# 9. Take top MAX_ADDS above threshold → add to Lidarr +# +# ============================================================================================== +# SCORING MODEL +# ============================================================================================== +# +# Stage 1 — seed selection (max 100) +# user_score (0-40) — effective plays × 5, cap 40 +# recency_score (0-30) — days since last play; today→30, older→less +# popularity_score (0-20) — Last.fm listeners +# quality_score (0-10) — Last.fm global playcount +# +# Stage 2 — candidate scoring (max 100) +# affinity_score (0-40) — seed_score × similarity, normalized; raw/20, cap 40 +# breadth_score (0-30) — 1 seed→5, 2 seeds→18, 3+seeds→30 +# popularity_score (0-20) — Last.fm listeners +# quality_score (0-10) — Last.fm global playcount +# +# Threshold: LIDARR_DISCOVERY_THRESHOLD (default 70) applied to both stages +# Max adds: LIDARR_DISCOVERY_MAX_ADDS (default 5) caps each stage +# +# ============================================================================================== +# REQUIREMENTS +# ============================================================================================== +# +# Last.fm API key — required for both stages +# Configure HOST*_LASTFM_API_KEY in master_host*.conf +# +# ============================================================================================== +# CONFIGURATION (master.conf) +# ============================================================================================== +# +# LIDARR_DISCOVERY_THRESHOLD — minimum score for Stage 1 seeds and Stage 2 adds (default: 70) +# LIDARR_DISCOVERY_LOOKBACK_DAYS — Emby play history window in days (default: 7) +# LIDARR_DISCOVERY_MIN_PLAYS — min plays to be evaluated in Stage 1 (default: 3) +# LIDARR_DISCOVERY_MAX_ADDS — max seeds (Stage 1) and max adds (Stage 2) (default: 5) +# LIDARR_DISCOVERY_USER_CAP_PCT — max % any one user contributes to play weight (default: 35) +# LIDARR_DISCOVERY_REJECT_COOLDOWN — days before re-evaluating a Stage 2 reject (default: 30) +# LIDARR_DISCOVERY_HISTORY — history/state file path +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# playback_aware_lidarr_discovery.sh — normal run +# playback_aware_lidarr_discovery.sh --dry-run — score and rank, no Lidarr changes +# playback_aware_lidarr_discovery.sh --log — verbose output +# playback_aware_lidarr_discovery.sh --status — show config and exit +# +# Recommended schedule: weekly (WEEKLY_MAINTENANCE_SCRIPTS in master.conf) # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" -source "$ROOT_DIR/load_config.sh" -source "$ROOT_DIR/Kernel/decision_engine.sh" +source "$SCRIPT_DIR/../load_config.sh" +source "$SCRIPT_DIR/../Kernel/decision_engine.sh" + +parse_args "$@" # ============================================================================================== -# ── CONFIG ──────────────────────────────────────────────────────────────────────────────────── +# ━━━ Setup ━━━ # ============================================================================================== +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi -DISCOVERY_THRESHOLD=70 +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 "${LIDARR_URL:-}" || -z "${LIDARR_API_KEY:-}" ]]; then + error "LIDARR_URL / LIDARR_API_KEY not configured — check master_host*.conf" + exit 1 +fi + +if [[ -z "${LASTFM_API_KEY:-}" ]]; then + error "LASTFM_API_KEY not configured — required for discovery" + error "Configure HOST*_LASTFM_API_KEY in master_host*.conf" + exit 1 +fi + +THRESHOLD="${LIDARR_DISCOVERY_THRESHOLD:-70}" +LOOKBACK_DAYS="${LIDARR_DISCOVERY_LOOKBACK_DAYS:-7}" +MIN_PLAYS="${LIDARR_DISCOVERY_MIN_PLAYS:-3}" +MAX_ADDS="${LIDARR_DISCOVERY_MAX_ADDS:-5}" +USER_CAP_PCT="${LIDARR_DISCOVERY_USER_CAP_PCT:-35}" +REJECT_COOLDOWN="${LIDARR_DISCOVERY_REJECT_COOLDOWN:-30}" +HISTORY_FILE="${LIDARR_DISCOVERY_HISTORY:-${DATA_DIR}/lidarr_discovery_history.db}" + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added to Lidarr" # ============================================================================================== -# ── EXAMPLE CANDIDATE ───────────────────────────────────────────────────────────────────────── +# ━━━ Status ━━━ # ============================================================================================== -# -# Real implementation would pull: -# -# Last.fm -# Trakt-style behavior history -# Lidarr metadata -# User weighting -# Genre affinity -# Temporal activity -# -# ============================================================================================== - -ARTIST_NAME="Example Artist" - -USER_SCORE=35 -POPULARITY_SCORE=15 -RECENCY_SCORE=10 -QUALITY_SCORE=20 +if [[ "$SHOW_STATUS" == true ]]; then + echo "" + echo "━━━━━ $ICON_SUMMARY LIDARR DISCOVERY STATUS ━━━━━" + echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" + echo "$ICON_SYNC Emby: ${EMBY_URL}" + echo "$ICON_SYNC Lidarr: ${LIDARR_URL}" + echo "$ICON_GEAR Threshold: ${THRESHOLD} / 100 (both stages)" + echo "$ICON_TIME Lookback: ${LOOKBACK_DAYS} days" + echo "$ICON_GEAR Min plays: ${MIN_PLAYS}" + echo "$ICON_GEAR Max adds: ${MAX_ADDS} (seeds in Stage 1, adds in Stage 2)" + echo "$ICON_GEAR User cap: ${USER_CAP_PCT}% max per user (floor: 3 plays)" + echo "$ICON_GEAR Reject TTL: ${REJECT_COOLDOWN} days" + echo "$ICON_GEAR History file: ${HISTORY_FILE}" + echo "$ICON_GEAR Last.fm: configured" + echo "$ICON_GEAR Dry Run: $DRY_RUN" + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +fi # ============================================================================================== -# ── SCORING ─────────────────────────────────────────────────────────────────────────────────── +# ── API HELPERS ─────────────────────────────────────────────────────────────────────────────── # ============================================================================================== -TOTAL_SCORE=$(score_candidate \ - "$USER_SCORE" \ - "$POPULARITY_SCORE" \ - "$RECENCY_SCORE" \ - "$QUALITY_SCORE" -) +_emby_api() { + local endpoint="$1" + local response http_code body + response=$(curl -sf --max-time 15 \ + -H "X-Emby-Token: $EMBY_API_KEY" \ + -w "\n%{http_code}" \ + "${EMBY_URL}/${endpoint}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + [[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $endpoint"; return 1; } + echo "$body" +} + +_lidarr_get() { + curl -sf --max-time 20 \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + "${LIDARR_URL}/api/v1/${1}" 2>/dev/null +} + +_lidarr_lookup() { + curl -sf --max-time 20 --get \ + --data-urlencode "term=$1" \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + "${LIDARR_URL}/api/v1/artist/lookup" 2>/dev/null +} + +_lidarr_post() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${LIDARR_URL}/api/v1/artist" 2>/dev/null +} + +_lastfm_info() { + curl -sf --max-time 10 --get \ + --data-urlencode "method=artist.getinfo" \ + --data-urlencode "artist=$1" \ + --data-urlencode "api_key=$LASTFM_API_KEY" \ + --data-urlencode "format=json" \ + "http://ws.audioscrobbler.com/2.0/" 2>/dev/null +} + +_lastfm_similar() { + curl -sf --max-time 15 --get \ + --data-urlencode "method=artist.getSimilar" \ + --data-urlencode "artist=$1" \ + --data-urlencode "limit=10" \ + --data-urlencode "api_key=$LASTFM_API_KEY" \ + --data-urlencode "format=json" \ + "http://ws.audioscrobbler.com/2.0/" 2>/dev/null +} # ============================================================================================== -# ── DECISION ────────────────────────────────────────────────────────────────────────────────── +# ── SCORING HELPERS ─────────────────────────────────────────────────────────────────────────── # ============================================================================================== -DECISION=$(make_decision "$TOTAL_SCORE" "$DISCOVERY_THRESHOLD") +_user_score() { + local s=$(( $1 * 5 )) + (( s > 40 )) && s=40 + echo "$s" +} + +_recency_score() { + local days="$1" + if (( days == 0 )); then echo 30 + elif (( days == 1 )); then echo 25 + elif (( days == 2 )); then echo 20 + elif (( days == 3 )); then echo 15 + elif (( days == 4 )); then echo 10 + elif (( days == 5 )); then echo 8 + else echo 5 + fi +} + +_affinity_score() { + local s=$(( $1 / 20 )) + (( s > 40 )) && s=40 + echo "$s" +} + +_breadth_score() { + local seeds="$1" + if (( seeds >= 3 )); then echo 30 + elif (( seeds == 2 )); then echo 18 + else echo 5 + fi +} + +_popularity_score() { + local listeners="$1" + if (( listeners >= 5000000 )); then echo 20 + elif (( listeners >= 1000000 )); then echo 15 + elif (( listeners >= 500000 )); then echo 10 + elif (( listeners >= 100000 )); then echo 5 + else echo 2 + fi +} + +_quality_score() { + local playcount="$1" + if (( playcount >= 100000000 )); then echo 10 + elif (( playcount >= 10000000 )); then echo 7 + elif (( playcount >= 1000000 )); then echo 4 + else echo 2 + fi +} + +_is_placeholder_artist() { + local a="${1,,}" + [[ "$a" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]] +} + +_lfm_scores() { + local artist="$1" + local LFM_JSON lfm_listeners lfm_playcount + LFM_JSON=$(_lastfm_info "$artist") + if [[ -n "$LFM_JSON" ]] && echo "$LFM_JSON" | jq -e '.artist' >/dev/null 2>&1; then + lfm_listeners=$(echo "$LFM_JSON" | jq -r '.artist.stats.listeners // "0"' | tr -d ',') + lfm_playcount=$(echo "$LFM_JSON" | jq -r '.artist.stats.playcount // "0"' | tr -d ',') + lfm_listeners=${lfm_listeners//[^0-9]/}; lfm_listeners=${lfm_listeners:-0} + lfm_playcount=${lfm_playcount//[^0-9]/}; lfm_playcount=${lfm_playcount:-0} + echo "${lfm_listeners}|${lfm_playcount}" + else + echo "0|0" + fi +} # ============================================================================================== -# ── OUTPUT ──────────────────────────────────────────────────────────────────────────────────── +# ━━━ Fetch Emby Play History ━━━ # ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY Lidarr 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 artists will be added" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "🎵 Lidarr Discovery Candidate" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "Artist: $ARTIST_NAME" -echo "Score : $TOTAL_SCORE" -echo "Result: $DECISION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "━━━ $ICON_SYNC Emby Play History ━━━" + +CUTOFF_ISO=$(date -d "${LOOKBACK_DAYS} days ago" '+%Y-%m-%dT%H:%M:%SZ') +TODAY_EPOCH=$(date +%s) +TODAY=$(date +%Y-%m-%d) + +ACTIVITY_JSON=$(_emby_api "System/ActivityLog/Entries?MinDate=${CUTOFF_ISO}&Limit=5000") || { + error "Could not fetch Emby activity log" + exit 1 +} + +declare -A ARTIST_PLAYS +declare -A ARTIST_LAST_PLAY +declare -A ARTIST_USER_PLAYS +declare -A ARTIST_EFFECTIVE # precomputed effective plays after user cap +TOTAL_PLAYS=0 + +while IFS='|' read -r play_date artist user_id; do + [[ -z "$artist" || "$artist" == "null" ]] && continue + _is_placeholder_artist "$artist" && continue + + (( TOTAL_PLAYS++ )) + ARTIST_PLAYS["$artist"]=$(( ${ARTIST_PLAYS["$artist"]:-0} + 1 )) + ARTIST_USER_PLAYS["${artist}|${user_id}"]=$(( ${ARTIST_USER_PLAYS["${artist}|${user_id}"]:-0} + 1 )) + + current="${ARTIST_LAST_PLAY["$artist"]:-}" + if [[ -z "$current" || "$play_date" > "$current" ]]; then + ARTIST_LAST_PLAY["$artist"]="$play_date" + fi +done < <(echo "$ACTIVITY_JSON" | jq -r ' + .Items // [] | .[] | + select(.Name | test("has finished playing"; "i")) | + select(.Name | test(" - ")) | + select(.Name | test(", Ep[0-9]") | not) | + [ + .Date, + ( + .Name | + split(" has finished playing ")[1] | + split(" on ") | .[0:-1] | join(" on ") | + split(" - ")[0] | ltrimstr(" ") | rtrimstr(" ") + ), + (.UserId // "unknown") + ] | join("|") +' 2>/dev/null) + +# Precompute effective plays (user cap applied) for all qualifying artists +for _a in "${!ARTIST_PLAYS[@]}"; do + _plays="${ARTIST_PLAYS["$_a"]}" + (( _plays < MIN_PLAYS )) && continue + _cap=$(( (_plays * USER_CAP_PCT + 99) / 100 )) + (( _cap < 3 )) && _cap=3 + _eff=0 + for _ukey in "${!ARTIST_USER_PLAYS[@]}"; do + [[ "$_ukey" == "${_a}|"* ]] || continue + _uc="${ARTIST_USER_PLAYS["$_ukey"]}" + (( _eff += _uc < _cap ? _uc : _cap )) + done + (( _eff == 0 )) && _eff="$_plays" + ARTIST_EFFECTIVE["$_a"]="$_eff" +done + +UNIQUE_ARTISTS=${#ARTIST_PLAYS[@]} +QUALIFIED=$(( $(echo "${!ARTIST_EFFECTIVE[@]}" | wc -w) )) +log "$TOTAL_PLAYS play completions | $UNIQUE_ARTISTS unique artists | $QUALIFIED qualify (${MIN_PLAYS}+ plays)" + +if [[ "$QUALIFIED" -eq 0 ]]; then + warn "No artists qualify this week — nothing to evaluate" + exit 0 +fi # ============================================================================================== -# ── ACTION ──────────────────────────────────────────────────────────────────────────────────── +# ━━━ Stage 1: Score Played Artists → Select Seeds ━━━ # ============================================================================================== -# -# Real implementation would: -# -# Add artist to Lidarr -# Queue search -# Log decision -# Record scoring metadata -# Update history state -# +echo "" +echo "━━━ $ICON_GEAR Stage 1: Scoring Played Artists ━━━" + +S1_SCORED=() # "score|artist" + +for artist in "${!ARTIST_EFFECTIVE[@]}"; do + effective="${ARTIST_EFFECTIVE["$artist"]}" + last_played="${ARTIST_LAST_PLAY["$artist"]}" + last_epoch=$(date -d "$last_played" +%s 2>/dev/null || echo "$TODAY_EPOCH") + days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 )) + + user_s=$(_user_score "$effective") + recency_s=$(_recency_score "$days_ago") + + IFS='|' read -r lfm_listeners lfm_playcount <<< "$(_lfm_scores "$artist")" + popularity_s=$(_popularity_score "$lfm_listeners") + quality_s=$(_quality_score "$lfm_playcount") + + total=$(score_candidate "$user_s" "$popularity_s" "$recency_s" "$quality_s") + total=$(apply_temporal_decay "$total" "$days_ago") + + log " [${total}] $artist (plays: ${ARTIST_PLAYS["$artist"]}→${effective} | ${days_ago}d ago)" + S1_SCORED+=("${total}|${artist}") +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_ADDS} seeds) ━━━" + +SEEDS=() +SEED_SCORES=() # parallel array: score for each seed (used as Stage 2 weight) + +for _entry in "${S1_SORTED[@]}"; do + (( ${#SEEDS[@]} >= MAX_ADDS )) && break + _score="${_entry%%|*}" + _artist="${_entry#*|}" + (( _score < THRESHOLD )) && break # sorted desc — below threshold means rest are too + SEEDS+=("$_artist") + SEED_SCORES+=("$_score") + printf " Seed [%3s] %s (plays: %s)\n" "$_score" "$_artist" "${ARTIST_PLAYS["$_artist"]}" +done + +if [[ "${#SEEDS[@]}" -eq 0 ]]; then + warn "No played artists scored above ${THRESHOLD} this week — no seeds for discovery" + exit 0 +fi + +log "${#SEEDS[@]} seed(s) selected" + # ============================================================================================== +# ━━━ Stage 2: Last.fm Similarity Discovery ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Stage 2: Last.fm Similarity Discovery ━━━" -if [[ "$DECISION" == "ACCEPT" ]]; then +declare -A CANDIDATE_SCORE +declare -A CANDIDATE_SEED_COUNT +declare -A CANDIDATE_LAST_SEED - echo "Adding artist to Lidarr..." - - # future: - # curl -X POST "$LIDARR_URL/api/v1/artist" - -else +for (( _i=0; _i<${#SEEDS[@]}; _i++ )); do + seed_artist="${SEEDS[$_i]}" + seed_score="${SEED_SCORES[$_i]}" + seed_last_play="${ARTIST_LAST_PLAY["$seed_artist"]}" - echo "Candidate rejected." + log " Seed: $seed_artist (score: $seed_score)" -fi \ No newline at end of file + SIMILAR_JSON=$(_lastfm_similar "$seed_artist") + if [[ -z "$SIMILAR_JSON" ]]; then + warn " Last.fm similar unavailable for: $seed_artist" + continue + fi + + while IFS='|' read -r sim_name sim_match; do + [[ -z "$sim_name" || "$sim_name" == "null" ]] && continue + _is_placeholder_artist "$sim_name" && continue + [[ "${sim_name,,}" == "${seed_artist,,}" ]] && continue + + sim_pct=$(echo "$sim_match" | awk '{printf "%d", $1 * 100 + 0.5}') + (( sim_pct < 1 )) && sim_pct=1 + + # Weight contribution by seed's Stage 1 score, not raw play count + contribution=$(( seed_score * sim_pct )) + CANDIDATE_SCORE["$sim_name"]=$(( ${CANDIDATE_SCORE["$sim_name"]:-0} + contribution )) + CANDIDATE_SEED_COUNT["$sim_name"]=$(( ${CANDIDATE_SEED_COUNT["$sim_name"]:-0} + 1 )) + + existing_date="${CANDIDATE_LAST_SEED["$sim_name"]:-}" + if [[ -z "$existing_date" || "$seed_last_play" > "$existing_date" ]]; then + CANDIDATE_LAST_SEED["$sim_name"]="$seed_last_play" + fi + done < <(echo "$SIMILAR_JSON" | jq -r ' + .similarartists.artist // [] | .[] | + [.name, .match] | join("|") + ' 2>/dev/null) +done + +CANDIDATE_COUNT=${#CANDIDATE_SCORE[@]} +log "$CANDIDATE_COUNT discovery candidates from Last.fm" + +if [[ "$CANDIDATE_COUNT" -eq 0 ]]; then + warn "No candidates returned from Last.fm — check API key or seed artist names" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Existing Libraries ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Existing Libraries ━━━" + +LIDARR_ARTISTS_JSON=$(_lidarr_get "artist") || { error "Could not fetch Lidarr artists"; exit 1; } +LIDARR_NAMES=$(echo "$LIDARR_ARTISTS_JSON" | jq -r '.[].artistName' 2>/dev/null) +LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || echo 0) +log "$LIDARR_COUNT artists in Lidarr" + +EMBY_LIBRARY_JSON=$(_emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000") || { + warn "Could not fetch Emby artist library — skipping Emby filter" + EMBY_ARTIST_NAMES="" +} +EMBY_ARTIST_NAMES=$(echo "$EMBY_LIBRARY_JSON" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null) +EMBY_ARTIST_COUNT=$(echo "$EMBY_ARTIST_NAMES" | grep -c . 2>/dev/null || echo 0) +log "$EMBY_ARTIST_COUNT album artists in Emby library" + +_in_lidarr() { echo "$LIDARR_NAMES" | grep -iq "^${1}$"; } +_in_emby_library(){ [[ -n "$EMBY_ARTIST_NAMES" ]] && echo "$EMBY_ARTIST_NAMES" | grep -iq "^${1}$"; } + +# ============================================================================================== +# ━━━ Score Stage 2 Candidates ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Stage 2: Scoring Candidates ━━━" + +ACCEPT_LIST=() +REJECT_LIST=() +ALREADY_KNOWN=0 +SKIP_COOLDOWN=0 + +for candidate in "${!CANDIDATE_SCORE[@]}"; do + raw_score="${CANDIDATE_SCORE["$candidate"]}" + seed_count="${CANDIDATE_SEED_COUNT["$candidate"]}" + last_seed_date="${CANDIDATE_LAST_SEED["$candidate"]:-}" + + if _in_lidarr "$candidate"; then + (( ALREADY_KNOWN++ )) + log " $ICON_SKIP In Lidarr: $candidate" + continue + fi + + if _in_emby_library "$candidate"; then + (( ALREADY_KNOWN++ )) + log " $ICON_SKIP In Emby: $candidate" + continue + fi + + if [[ -f "$HISTORY_FILE" ]]; then + last_rejection=$(grep -i "^REJECT|${candidate}|" "$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): $candidate" + continue + fi + fi + fi + + if [[ -n "$last_seed_date" ]]; then + last_epoch=$(date -d "$last_seed_date" +%s 2>/dev/null || echo "$TODAY_EPOCH") + days_ago=$(( (TODAY_EPOCH - last_epoch) / 86400 )) + else + days_ago=0 + fi + + if (( seed_count < 2 )); then + log " $ICON_SKIP Single-seed candidate skipped: $candidate" + REJECT_LIST+=("0|${candidate}|${seed_count}|0|single-seed skip") + continue + fi + + affinity_s=$(_affinity_score "$raw_score") + breadth_s=$(_breadth_score "$seed_count") + + IFS='|' read -r lfm_listeners lfm_playcount <<< "$(_lfm_scores "$candidate")" + if (( lfm_listeners > 0 || lfm_playcount > 0 )); then + lfm_label="${lfm_listeners} lfm listeners" + popularity_s=$(_popularity_score "$lfm_listeners") + quality_s=$(_quality_score "$lfm_playcount") + else + lfm_label="not on Last.fm" + popularity_s=0 + quality_s=0 + fi + + total=$(score_candidate "$affinity_s" "$popularity_s" "$breadth_s" "$quality_s") + total=$(apply_temporal_decay "$total" "$days_ago") + decision=$(make_decision "$total" "$THRESHOLD") + + entry="${total}|${candidate}|${seed_count}|${affinity_s}|${lfm_label}" + [[ "$decision" == "ACCEPT" ]] && ACCEPT_LIST+=("$entry") || REJECT_LIST+=("$entry") +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)) + +# Cap to MAX_ADDS — overflow goes to reject history so they don't resurface for REJECT_COOLDOWN days +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 name seeds affinity lfm <<< "$entry" + printf " %-8s [%3s] %-40s seeds: %s | affinity: %2s | %s\n" \ + "$label" "$score" "$name" "$seeds" "$affinity" "$lfm" +} + +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 | 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 artists" + exit 0 +fi + +if [[ "${#SORTED_ACCEPTS[@]}" -eq 0 ]]; then + log "No artists above threshold — nothing to add" + for entry in "${SORTED_REJECTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score name _ <<< "$entry" + echo "REJECT|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null + done + exit 0 +fi + +# ============================================================================================== +# ━━━ Add to Lidarr ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Adding to Lidarr ━━━" + +LIDARR_ROOT=$(_lidarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null) +if [[ -z "$LIDARR_ROOT" ]]; then error "Could not determine Lidarr root folder"; exit 1; fi + +QUALITY_PROFILES=$(_lidarr_get "qualityprofile") || { error "Could not fetch quality profiles"; exit 1; } +METADATA_PROFILES=$(_lidarr_get "metadataprofile") || { error "Could not fetch metadata profiles"; exit 1; } + +DEFAULT_QUALITY_ID=$(echo "$QUALITY_PROFILES" | jq -r '.[0].id' 2>/dev/null) +DEFAULT_METADATA_ID=$(echo "$METADATA_PROFILES" | jq -r ' + first(.[] | select(.name | test("Standard"; "i")) | .id) // .[0].id' 2>/dev/null) + +log "Quality profile: $DEFAULT_QUALITY_ID | Metadata profile: $DEFAULT_METADATA_ID" + +ADDED=0 +FAILED=0 + +for entry in "${SORTED_ACCEPTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score name seeds affinity lfm <<< "$entry" + + LOOKUP=$(_lidarr_lookup "$name") + + if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then + warn " $ICON_WARN No match in Lidarr lookup: $name" + echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + continue + fi + + ARTIST_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null) + MBID=$(echo "$ARTIST_DATA" | jq -r '.foreignArtistId // ""' 2>/dev/null) + LIDARR_NAME=$(echo "$ARTIST_DATA" | jq -r '.artistName // ""' 2>/dev/null) + + if [[ -z "$MBID" ]]; then + warn " $ICON_WARN No MusicBrainz ID for: $name" + echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + continue + fi + + PAYLOAD=$(echo "$ARTIST_DATA" | jq \ + --arg root "$LIDARR_ROOT" \ + --argjson qid "$DEFAULT_QUALITY_ID" \ + --argjson mid "$DEFAULT_METADATA_ID" \ + '. + { + rootFolderPath: $root, + qualityProfileId: $qid, + metadataProfileId: $mid, + monitored: true, + addOptions: { + monitor: "all", + searchForMissingAlbums: true + } + }' 2>/dev/null) + + RESULT=$(_lidarr_post "$PAYLOAD") + + if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + log " $ICON_DONE Added: $LIDARR_NAME (score: $score | seeds: $seeds)" + echo "ACCEPT|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null + (( ADDED++ )) + else + warn " $ICON_WARN Failed to add: $name" + log " $(echo "$RESULT" | head -c 200)" + echo "FAIL|${name}|${TODAY}" >> "$HISTORY_FILE" 2>/dev/null + (( FAILED++ )) + fi +done + +for entry in "${SORTED_REJECTS[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r score name _ <<< "$entry" + echo "REJECT|${name}|${TODAY}" >> "$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 artist(s) added to Lidarr via discovery on $(hostname)" \ + "Lidarr Discovery" "normal" + +exit 0 diff --git a/Tools/Manual-Tools.md b/Tools/Manual-Tools.md index edd42ed..0e1a540 100644 --- a/Tools/Manual-Tools.md +++ b/Tools/Manual-Tools.md @@ -8,6 +8,9 @@ making any changes. ## ━━━ CONTENTS ━━━ +- [emby_to_lidarr_sync.sh](#emby_to_lidarr_syncsh) +- [emby_to_sonarr_sync.sh](#emby_to_sonarr_syncsh) +- [emby_to_radarr_sync.sh](#emby_to_radarr_syncsh) - [failover_state_reset.sh](#failover_state_resetsh) - [watchdog_skip_list_manager.sh](#watchdog_skip_list_managersh) - [bulk_permissions_repair.sh](#bulk_permissions_repairsh) @@ -21,6 +24,104 @@ making any changes. --- +## emby_to_lidarr_sync.sh + +One-shot bootstrap tool. Scans Emby play history, finds artists you've actually +listened to that are not yet tracked in Lidarr, and adds them. No scoring — if +it was played, Lidarr should monitor it. Not scheduled; run manually when you +want to close the gap between what's in your library and what Lidarr watches. + +### When to Use + +- After initial Lidarr setup — bring it in line with existing listening history +- After a Lidarr database wipe or migration +- Any time you suspect artists you listen to are slipping through unmonitored + +### Usage + +```bash +# See what would be added (no changes) +bash Tools/emby_to_lidarr_sync.sh --dry-run + +# Limit to recent plays only +bash Tools/emby_to_lidarr_sync.sh --dry-run --days 30 + +# Run for real +bash Tools/emby_to_lidarr_sync.sh +``` + +### Notes + +- Reads completions from the Emby activity log (`has finished playing` events) +- Filters out VA, Various Artists, and other metadata placeholders +- Uses `searchForMissingAlbums: false` — adds monitoring without triggering a + full album search; run Lidarr's own missing-album search afterwards if desired +- Activity log has a finite history; use `--days N` if the log has been pruned + +--- + +## emby_to_sonarr_sync.sh + +One-shot bootstrap tool. Finds TV series present in Emby that are not tracked in +Sonarr and adds them. Uses TVDB ID matching when available (more reliable than +title matching), falling back to case-insensitive title comparison. + +### When to Use + +- After initial Sonarr setup — bring it in line with your existing library +- After a Sonarr database wipe or migration +- Any time series you own are slipping through unmonitored + +### Usage + +```bash +# See what would be added (no changes) +bash Tools/emby_to_sonarr_sync.sh --dry-run + +# Run for real +bash Tools/emby_to_sonarr_sync.sh +``` + +### Notes + +- Uses `searchForMissingEpisodes: false` — adds monitoring without triggering + episode searches; Sonarr's library scan will pick up existing files +- Adds to the first accessible root folder in Sonarr (`/tv` by default) +- TVDB ID match preferred over title; title fallback handles edge cases + +--- + +## emby_to_radarr_sync.sh + +One-shot bootstrap tool. Finds movies present in Emby that are not tracked in +Radarr and adds them. Uses TMDB ID matching when available, falling back to +case-insensitive title comparison. + +### When to Use + +- After initial Radarr setup — bring it in line with your existing library +- After a Radarr database wipe or migration +- Any time movies you own are slipping through unmonitored + +### Usage + +```bash +# See what would be added (no changes) +bash Tools/emby_to_radarr_sync.sh --dry-run + +# Run for real +bash Tools/emby_to_radarr_sync.sh +``` + +### Notes + +- Uses `searchForMovie: false` — adds monitoring without triggering movie + searches; Radarr's library scan will pick up existing files +- Adds to the first accessible root folder in Radarr (`/movies` by default) +- TMDB ID match preferred over title; title fallback handles edge cases + +--- + ## failover_state_reset.sh Resets the fallback state file to NORMAL and clears all tier flags. State file only — diff --git a/Tools/emby_to_lidarr_sync.sh b/Tools/emby_to_lidarr_sync.sh new file mode 100755 index 0000000..f0d1a72 --- /dev/null +++ b/Tools/emby_to_lidarr_sync.sh @@ -0,0 +1,291 @@ +#!/bin/bash +# ============================================================================================== +# =============================== Emby → Lidarr Sync =========================================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# One-shot tool. Finds artists played on Emby that are not tracked in Lidarr +# and adds them. No scoring — if you played it, Lidarr should monitor it. +# +# Intended as a bootstrap / catch-up tool, not a scheduled script. Run it once +# after Lidarr is set up, or any time you suspect gaps between what you listen +# to and what Lidarr monitors. +# +# ============================================================================================== +# FLOW +# ============================================================================================== +# +# 1. Pull play completions from Emby activity log (all time, or --days N) +# 2. Fetch current Lidarr artist library +# 3. For each played artist not in Lidarr → add to Lidarr +# +# ============================================================================================== +# USAGE +# ============================================================================================== +# +# emby_to_lidarr_sync.sh — add all played artists not in Lidarr +# emby_to_lidarr_sync.sh --dry-run — show what would be added, no changes +# emby_to_lidarr_sync.sh --days N — limit to plays in the last N days +# emby_to_lidarr_sync.sh --log — verbose output +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +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" + 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 "${LIDARR_URL:-}" || -z "${LIDARR_API_KEY:-}" ]]; then + error "LIDARR_URL / LIDARR_API_KEY not configured — check master_host*.conf" + exit 1 +fi + + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added to Lidarr" + +# ============================================================================================== +# ── API HELPERS ─────────────────────────────────────────────────────────────────────────────── +# ============================================================================================== + +_emby_api() { + local endpoint="$1" + local response http_code body + response=$(curl -sf --max-time 30 \ + -H "X-Emby-Token: $EMBY_API_KEY" \ + -w "\n%{http_code}" \ + "${EMBY_URL}/${endpoint}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + [[ "$http_code" != "200" ]] && { error "Emby API HTTP $http_code: $endpoint"; return 1; } + echo "$body" +} + +_lidarr_get() { + curl -sf --max-time 30 \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + "${LIDARR_URL}/api/v1/${1}" 2>/dev/null +} + +_lidarr_lookup() { + curl -sf --max-time 20 --get \ + --data-urlencode "term=$1" \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + "${LIDARR_URL}/api/v1/artist/lookup" 2>/dev/null +} + +_lidarr_post() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $LIDARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${LIDARR_URL}/api/v1/artist" 2>/dev/null +} + +_is_placeholder_artist() { + local a="${1,,}" + [[ "$a" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]] +} + +# Dirty tag: comma-list, feat./ft., ampersand join, or "Artist - Album" in the AlbumArtist field +_is_dirty_artist() { + local lower="${1,,}" + [[ "$1" == *", "* ]] && return 0 + [[ "$lower" == *" feat."* ]] && return 0 + [[ "$lower" == *" ft."* ]] && return 0 + [[ "$1" == *" & "* ]] && return 0 + [[ "$lower" == *" vs "* ]] && return 0 + [[ "$1" == *" - "* ]] && return 0 + return 1 +} + +# ============================================================================================== +# ━━━ Fetch Emby Music Artist Library ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY Emby → Lidarr Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━" +echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)" +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added" + +echo "" +echo "━━━ $ICON_SYNC Emby Music Library ━━━" + +# Query MusicAlbum items and extract AlbumArtists — gives primary album artists only, +# not the full tag credits (guest features, songwriters, etc.) that MusicArtist returns +EMBY_ALBUMS_JSON=$(_emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000") || { + error "Could not fetch Emby music albums" + exit 1 +} + +declare -A PLAYED_ARTISTS + +while IFS= read -r artist; do + [[ -z "$artist" || "$artist" == "null" ]] && continue + _is_placeholder_artist "$artist" && continue + _is_dirty_artist "$artist" && { log " $ICON_SKIP Skipping dirty tag: $artist"; continue; } + PLAYED_ARTISTS["$artist"]=1 +done < <(echo "$EMBY_ALBUMS_JSON" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null) + +PLAYED_COUNT=${#PLAYED_ARTISTS[@]} +log "$PLAYED_COUNT album artists in Emby library" + +if [[ "$PLAYED_COUNT" -eq 0 ]]; then + warn "No album artists found in Emby library" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Lidarr Library ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Lidarr Library ━━━" + +LIDARR_ARTISTS_JSON=$(_lidarr_get "artist") || { error "Could not fetch Lidarr artists"; exit 1; } +LIDARR_NAMES=$(echo "$LIDARR_ARTISTS_JSON" | jq -r '.[].artistName' 2>/dev/null) +LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || echo 0) +log "$LIDARR_COUNT artists already in Lidarr" + +_in_lidarr() { + echo "$LIDARR_NAMES" | grep -iq "^${1}$" +} + +# ============================================================================================== +# ━━━ Find Gaps ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Comparing Libraries ━━━" + +MISSING=() +ALREADY=0 + +for artist in "${!PLAYED_ARTISTS[@]}"; do + if _in_lidarr "$artist"; then + (( ALREADY++ )) + log " $ICON_SKIP Already tracked: $artist" + else + MISSING+=("$artist") + log " $ICON_WARN Not in Lidarr: $artist" + fi +done + +IFS=$'\n' MISSING=($(printf '%s\n' "${MISSING[@]}" | sort)) + +echo " Already tracked: $ALREADY | Missing from Lidarr: ${#MISSING[@]}" + +if [[ "${#MISSING[@]}" -eq 0 ]]; then + log "Lidarr already tracks everything played on Emby" + exit 0 +fi + +echo "" +echo "━━━ $ICON_SUMMARY Artists to add (${#MISSING[@]}) ━━━" +for a in "${MISSING[@]}"; do echo " $a"; done + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN complete — run without --dry-run to add these artists" + exit 0 +fi + +# ============================================================================================== +# ━━━ Add to Lidarr ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Adding to Lidarr ━━━" + +LIDARR_ROOT=$(_lidarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null) +if [[ -z "$LIDARR_ROOT" ]]; then error "Could not determine Lidarr root folder"; exit 1; fi + +QUALITY_PROFILES=$(_lidarr_get "qualityprofile") || { error "Could not fetch quality profiles"; exit 1; } +METADATA_PROFILES=$(_lidarr_get "metadataprofile") || { error "Could not fetch metadata profiles"; exit 1; } + +DEFAULT_QUALITY_ID=$(echo "$QUALITY_PROFILES" | jq -r '.[0].id' 2>/dev/null) +DEFAULT_METADATA_ID=$(echo "$METADATA_PROFILES" | jq -r ' + first(.[] | select(.name | test("Standard"; "i")) | .id) // .[0].id' 2>/dev/null) + +log "Quality profile: $DEFAULT_QUALITY_ID | Metadata profile: $DEFAULT_METADATA_ID" + +ADDED=0 +FAILED=0 +SKIPPED=0 + +for artist in "${MISSING[@]}"; do + LOOKUP=$(_lidarr_lookup "$artist") + + if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then + warn " $ICON_WARN No match in Lidarr lookup: $artist" + (( FAILED++ )) + continue + fi + + ARTIST_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null) + MBID=$(echo "$ARTIST_DATA" | jq -r '.foreignArtistId // ""' 2>/dev/null) + LIDARR_NAME=$(echo "$ARTIST_DATA" | jq -r '.artistName // ""' 2>/dev/null) + + if [[ -z "$MBID" ]]; then + warn " $ICON_WARN No MusicBrainz ID for: $artist" + (( FAILED++ )) + continue + fi + + PAYLOAD=$(echo "$ARTIST_DATA" | jq \ + --arg root "$LIDARR_ROOT" \ + --argjson qid "$DEFAULT_QUALITY_ID" \ + --argjson mid "$DEFAULT_METADATA_ID" \ + '. + { + rootFolderPath: $root, + qualityProfileId: $qid, + metadataProfileId: $mid, + monitored: true, + addOptions: { + monitor: "all", + searchForMissingAlbums: false + } + }' 2>/dev/null) + + RESULT=$(_lidarr_post "$PAYLOAD") + + if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + log " $ICON_DONE Added: $LIDARR_NAME" + (( ADDED++ )) + else + warn " $ICON_WARN Failed to add: $artist" + log " $(echo "$RESULT" | head -c 200)" + (( FAILED++ )) + fi +done + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY SYNC COMPLETE ━━━━━" +echo " $ICON_DONE Added: $ADDED" +[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED" +echo " $ICON_SKIP Already tracked: $ALREADY" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[[ "$ADDED" -gt 0 ]] && notify \ + "$ADDED artist(s) added to Lidarr from Emby play history on $(hostname)" \ + "Emby → Lidarr Sync" "normal" + +exit 0 diff --git a/Tools/emby_to_radarr_sync.sh b/Tools/emby_to_radarr_sync.sh new file mode 100644 index 0000000..4b1e599 --- /dev/null +++ b/Tools/emby_to_radarr_sync.sh @@ -0,0 +1,282 @@ +#!/bin/bash +# ============================================================================================== +# =============================== Emby → Radarr Sync ========================================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# One-shot tool. Finds movies present in Emby that are not tracked in Radarr +# and adds them. No scoring — if it's in your library, Radarr should monitor it. +# +# Intended as a bootstrap / catch-up tool. Run after Radarr setup, after a +# database wipe, or any time you suspect gaps between your library and Radarr. +# +# ============================================================================================== +# FLOW +# ============================================================================================== +# +# 1. Fetch all Movie items from Emby (with TMDB provider IDs) +# 2. Fetch current Radarr library (indexed by TMDB ID and title) +# 3. For each Emby movie not in Radarr → add to Radarr +# +# Matching prefers TMDB ID when available (immune to title differences), +# falling back to case-insensitive title comparison. +# +# ============================================================================================== +# USAGE +# ============================================================================================== +# +# emby_to_radarr_sync.sh — add all untracked movies to Radarr +# emby_to_radarr_sync.sh --dry-run — show what would be added, no changes +# emby_to_radarr_sync.sh --log — verbose output +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +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" + 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 "${RADARR_URL:-}" || -z "${RADARR_API_KEY:-}" ]]; then + error "RADARR_URL / RADARR_API_KEY not configured — check master_host*.conf" + exit 1 +fi + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added to Radarr" + +# ============================================================================================== +# ── 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" +} + +_radarr_get() { + curl -sf --max-time 30 \ + -H "X-Api-Key: $RADARR_API_KEY" \ + "${RADARR_URL}/api/v3/${1}" 2>/dev/null +} + +_radarr_lookup() { + curl -sf --max-time 20 --get \ + --data-urlencode "term=$1" \ + -H "X-Api-Key: $RADARR_API_KEY" \ + "${RADARR_URL}/api/v3/movie/lookup" 2>/dev/null +} + +_radarr_post() { + curl -sf --max-time 20 -X POST \ + -H "X-Api-Key: $RADARR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" \ + "${RADARR_URL}/api/v3/movie" 2>/dev/null +} + +# ============================================================================================== +# ━━━ Fetch Emby Movie Library ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY Emby → Radarr Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━" +echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)" +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no movies will be added" + +echo "" +echo "━━━ $ICON_SYNC Emby Movie Library ━━━" + +EMBY_MOVIES_JSON=$(_emby_api "Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds&Limit=10000") || { + error "Could not fetch Emby movies" + exit 1 +} + +declare -A EMBY_MOVIES # name → tmdb_id (empty string if none) + +while IFS='|' read -r name tmdb_id; do + [[ -z "$name" || "$name" == "null" ]] && continue + EMBY_MOVIES["$name"]="$tmdb_id" +done < <(echo "$EMBY_MOVIES_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tmdb // "")] | join("|")' 2>/dev/null) + +EMBY_COUNT=${#EMBY_MOVIES[@]} +log "$EMBY_COUNT movies in Emby library" + +if [[ "$EMBY_COUNT" -eq 0 ]]; then + warn "No movies found in Emby library" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Radarr Library ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Radarr Library ━━━" + +RADARR_MOVIES_JSON=$(_radarr_get "movie") || { error "Could not fetch Radarr movies"; exit 1; } + +declare -A RADARR_TMDB # tmdb_id → 1 +declare -A RADARR_TITLES # lower(title) → 1 + +while IFS='|' read -r title tmdb_id; do + [[ -n "$title" ]] && RADARR_TITLES["${title,,}"]=1 + [[ -n "$tmdb_id" && "$tmdb_id" != "0" ]] && RADARR_TMDB["$tmdb_id"]=1 +done < <(echo "$RADARR_MOVIES_JSON" | jq -r '.[] | [.title, (.tmdbId // 0 | tostring)] | join("|")' 2>/dev/null) + +RADARR_COUNT=${#RADARR_TITLES[@]} +log "$RADARR_COUNT movies already in Radarr" + +# ============================================================================================== +# ━━━ Find Gaps ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Comparing Libraries ━━━" + +declare -A MISSING # name → tmdb_id +ALREADY=0 + +for name in "${!EMBY_MOVIES[@]}"; do + tmdb_id="${EMBY_MOVIES[$name]}" + + if [[ -n "$tmdb_id" && "${RADARR_TMDB[$tmdb_id]+x}" ]]; then + (( ALREADY++ )) + log " $ICON_SKIP Already tracked (TMDB $tmdb_id): $name" + continue + fi + + if [[ "${RADARR_TITLES[${name,,}]+x}" ]]; then + (( ALREADY++ )) + log " $ICON_SKIP Already tracked (title): $name" + continue + fi + + MISSING["$name"]="$tmdb_id" + log " $ICON_WARN Not in Radarr: $name${tmdb_id:+ (TMDB: $tmdb_id)}" +done + +IFS=$'\n' SORTED_MISSING=($(printf '%s\n' "${!MISSING[@]}" | sort)) + +echo " Already tracked: $ALREADY | Missing from Radarr: ${#MISSING[@]}" + +if [[ "${#MISSING[@]}" -eq 0 ]]; then + log "Radarr already tracks everything in Emby" + exit 0 +fi + +echo "" +echo "━━━ $ICON_SUMMARY Movies to add (${#MISSING[@]}) ━━━" +for name in "${SORTED_MISSING[@]}"; do + _tmdb="${MISSING[$name]}" + echo " $name${_tmdb:+ (TMDB: $_tmdb)}" +done + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN complete — run without --dry-run to add these movies" + exit 0 +fi + +# ============================================================================================== +# ━━━ Add to Radarr ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Adding to Radarr ━━━" + +RADARR_ROOT=$(_radarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null) +QUALITY_ID=$(_radarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null) + +if [[ -z "$RADARR_ROOT" ]]; then + error "Could not determine Radarr root folder" + exit 1 +fi +log "Root folder: $RADARR_ROOT | Quality profile: $QUALITY_ID" + +ADDED=0 +FAILED=0 + +for name in "${SORTED_MISSING[@]}"; do + tmdb_id="${MISSING[$name]}" + + if [[ -n "$tmdb_id" ]]; then + LOOKUP=$(_radarr_lookup "tmdb:$tmdb_id") + else + LOOKUP=$(_radarr_lookup "$name") + fi + + if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then + warn " $ICON_WARN No match in Radarr lookup: $name" + (( FAILED++ )) + continue + fi + + MOVIE_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null) + RADARR_TITLE=$(echo "$MOVIE_DATA" | jq -r '.title // ""' 2>/dev/null) + + if [[ -z "$RADARR_TITLE" ]]; then + warn " $ICON_WARN Empty result for: $name" + (( FAILED++ )) + continue + fi + + PAYLOAD=$(echo "$MOVIE_DATA" | jq \ + --arg root "$RADARR_ROOT" \ + --argjson qid "$QUALITY_ID" \ + '. + { + rootFolderPath: $root, + qualityProfileId: $qid, + monitored: true, + addOptions: { + searchForMovie: false + } + }' 2>/dev/null) + + RESULT=$(_radarr_post "$PAYLOAD") + + if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + log " $ICON_DONE Added: $RADARR_TITLE" + (( ADDED++ )) + else + warn " $ICON_WARN Failed to add: $name" + log " $(echo "$RESULT" | head -c 200)" + (( FAILED++ )) + fi +done + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY SYNC COMPLETE ━━━━━" +echo " $ICON_DONE Added: $ADDED" +[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED" +echo " $ICON_SKIP Already tracked: $ALREADY" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[[ "$ADDED" -gt 0 ]] && notify \ + "$ADDED movie(s) added to Radarr from Emby library on $(hostname)" \ + "Emby → Radarr Sync" "normal" + +exit 0 diff --git a/Tools/emby_to_sonarr_sync.sh b/Tools/emby_to_sonarr_sync.sh new file mode 100644 index 0000000..657a651 --- /dev/null +++ b/Tools/emby_to_sonarr_sync.sh @@ -0,0 +1,284 @@ +#!/bin/bash +# ============================================================================================== +# =============================== Emby → Sonarr Sync ========================================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# One-shot tool. Finds TV series present in Emby that are not tracked in Sonarr +# and adds them. No scoring — if it's in your library, Sonarr should monitor it. +# +# Intended as a bootstrap / catch-up tool. Run after Sonarr setup, after a +# database wipe, or any time you suspect gaps between your library and Sonarr. +# +# ============================================================================================== +# FLOW +# ============================================================================================== +# +# 1. Fetch all Series items from Emby (with TVDB provider IDs) +# 2. Fetch current Sonarr library (indexed by TVDB ID and title) +# 3. For each Emby series not in Sonarr → add to Sonarr +# +# Matching prefers TVDB ID when available (immune to title differences), +# falling back to case-insensitive title comparison. +# +# ============================================================================================== +# USAGE +# ============================================================================================== +# +# emby_to_sonarr_sync.sh — add all untracked series to Sonarr +# emby_to_sonarr_sync.sh --dry-run — show what would be added, no changes +# emby_to_sonarr_sync.sh --log — verbose output +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +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" + 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 + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no series will be added to Sonarr" + +# ============================================================================================== +# ── 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 +} + +# ============================================================================================== +# ━━━ Fetch Emby Series Library ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY Emby → Sonarr Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━" +echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)" +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no series will be added" + +echo "" +echo "━━━ $ICON_SYNC Emby Series Library ━━━" + +EMBY_SERIES_JSON=$(_emby_api "Items?IncludeItemTypes=Series&Recursive=true&Fields=ProviderIds&Limit=5000") || { + error "Could not fetch Emby series" + exit 1 +} + +declare -A EMBY_SERIES # name → tvdb_id (empty string if none) + +while IFS='|' read -r name tvdb_id; do + [[ -z "$name" || "$name" == "null" ]] && continue + EMBY_SERIES["$name"]="$tvdb_id" +done < <(echo "$EMBY_SERIES_JSON" | jq -r '.Items[] | [.Name, (.ProviderIds.Tvdb // "")] | join("|")' 2>/dev/null) + +EMBY_COUNT=${#EMBY_SERIES[@]} +log "$EMBY_COUNT series in Emby library" + +if [[ "$EMBY_COUNT" -eq 0 ]]; then + warn "No series found in Emby library" + exit 0 +fi + +# ============================================================================================== +# ━━━ Fetch Sonarr Library ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SYNC Sonarr Library ━━━" + +SONARR_SERIES_JSON=$(_sonarr_get "series") || { error "Could not fetch Sonarr series"; exit 1; } + +declare -A SONARR_TVDB # tvdb_id → 1 +declare -A SONARR_TITLES # lower(title) → 1 + +while IFS='|' read -r title tvdb_id; do + [[ -n "$title" ]] && SONARR_TITLES["${title,,}"]=1 + [[ -n "$tvdb_id" && "$tvdb_id" != "0" ]] && SONARR_TVDB["$tvdb_id"]=1 +done < <(echo "$SONARR_SERIES_JSON" | jq -r '.[] | [.title, (.tvdbId // 0 | tostring)] | join("|")' 2>/dev/null) + +SONARR_COUNT=${#SONARR_TITLES[@]} +log "$SONARR_COUNT series already in Sonarr" + +# ============================================================================================== +# ━━━ Find Gaps ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Comparing Libraries ━━━" + +declare -A MISSING # name → tvdb_id +ALREADY=0 + +for name in "${!EMBY_SERIES[@]}"; do + tvdb_id="${EMBY_SERIES[$name]}" + + if [[ -n "$tvdb_id" && "${SONARR_TVDB[$tvdb_id]+x}" ]]; then + (( ALREADY++ )) + log " $ICON_SKIP Already tracked (TVDB $tvdb_id): $name" + continue + fi + + if [[ "${SONARR_TITLES[${name,,}]+x}" ]]; then + (( ALREADY++ )) + log " $ICON_SKIP Already tracked (title): $name" + continue + fi + + MISSING["$name"]="$tvdb_id" + log " $ICON_WARN Not in Sonarr: $name${tvdb_id:+ (TVDB: $tvdb_id)}" +done + +IFS=$'\n' SORTED_MISSING=($(printf '%s\n' "${!MISSING[@]}" | sort)) + +echo " Already tracked: $ALREADY | Missing from Sonarr: ${#MISSING[@]}" + +if [[ "${#MISSING[@]}" -eq 0 ]]; then + log "Sonarr already tracks everything in Emby" + exit 0 +fi + +echo "" +echo "━━━ $ICON_SUMMARY Series to add (${#MISSING[@]}) ━━━" +for name in "${SORTED_MISSING[@]}"; do + _tvdb="${MISSING[$name]}" + echo " $name${_tvdb:+ (TVDB: $_tvdb)}" +done + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN complete — run without --dry-run to add these series" + 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) +QUALITY_ID=$(_sonarr_get "qualityprofile" | jq -r '.[0].id' 2>/dev/null) + +if [[ -z "$SONARR_ROOT" ]]; then + error "Could not determine Sonarr root folder" + exit 1 +fi +log "Root folder: $SONARR_ROOT | Quality profile: $QUALITY_ID" + +ADDED=0 +FAILED=0 + +for name in "${SORTED_MISSING[@]}"; do + tvdb_id="${MISSING[$name]}" + + if [[ -n "$tvdb_id" ]]; then + LOOKUP=$(_sonarr_lookup "tvdb:$tvdb_id") + else + LOOKUP=$(_sonarr_lookup "$name") + fi + + if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then + warn " $ICON_WARN No match in Sonarr lookup: $name" + (( 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: $name" + (( FAILED++ )) + continue + fi + + PAYLOAD=$(echo "$SERIES_DATA" | jq \ + --arg root "$SONARR_ROOT" \ + --argjson qid "$QUALITY_ID" \ + '. + { + rootFolderPath: $root, + qualityProfileId: $qid, + monitored: true, + seasonFolder: true, + addOptions: { + monitor: "all", + searchForMissingEpisodes: false + } + }' 2>/dev/null) + + RESULT=$(_sonarr_post "$PAYLOAD") + + if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then + log " $ICON_DONE Added: $SONARR_TITLE" + (( ADDED++ )) + else + warn " $ICON_WARN Failed to add: $name" + log " $(echo "$RESULT" | head -c 200)" + (( FAILED++ )) + fi +done + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY SYNC COMPLETE ━━━━━" +echo " $ICON_DONE Added: $ADDED" +[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED" +echo " $ICON_SKIP Already tracked: $ALREADY" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +[[ "$ADDED" -gt 0 ]] && notify \ + "$ADDED series added to Sonarr from Emby library on $(hostname)" \ + "Emby → Sonarr Sync" "normal" + +exit 0 diff --git a/master.conf b/master.conf index 99e0e34..4a7b344 100644 --- a/master.conf +++ b/master.conf @@ -380,7 +380,7 @@ "Docker_Essentials/docker_weekly_restart.sh" # weekly container restarts after sync "Docker_Essentials/docker_update_remaining.sh" # pull updates for all other containers "unRAID_Essentials/clear_logs.sh" # purge aged logs — Sunday only, low priority - #"Media/playback_aware_lidarr_discovery.sh" # behavior-driven music discovery using weekly Emby playback history — WIP + "Media/playback_aware_lidarr_discovery.sh" # behavior-driven music discovery using weekly Emby playback history ) # Pull updates for all running containers NOT in daily/weekly restart lists. @@ -940,6 +940,15 @@ LIDARR_ART_SLEEP_BETWEEN=0.2 # seconds between fanart.tv API calls # HOST*_FANART_API_KEY / HOST*_LASTFM_API_KEY — set in master_host*.conf +# Lidarr discovery settings (playback_aware_lidarr_discovery.sh) + LIDARR_DISCOVERY_THRESHOLD=70 # score to accept candidate (0-100) + LIDARR_DISCOVERY_LOOKBACK_DAYS=7 # Emby play history window in days + LIDARR_DISCOVERY_MIN_PLAYS=3 # min plays in window before evaluating an artist + LIDARR_DISCOVERY_USER_CAP_PCT=35 # max % any single user can contribute to play score (prevents one listener dominating) + LIDARR_DISCOVERY_MAX_ADDS=5 # max artists to add per run — quality over bulk + LIDARR_DISCOVERY_REJECT_COOLDOWN=30 # days before re-evaluating a rejected artist + LIDARR_DISCOVERY_HISTORY="$DATA_DIR/lidarr_discovery_history.db" + # Sonarr shared settings SONARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion SONARR_MAX_DELETE_GB=10 # require --i-know-what-im-doing if deletion exceeds this