feat: two-stage Lidarr discovery + Emby→arr sync tools

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
This commit is contained in:
Gmer4Lfe
2026-05-19 23:34:32 -04:00
parent b4d2a90568
commit 7ccb7e0e67
6 changed files with 1641 additions and 67 deletions
+673 -66
View File
@@ -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: 05 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
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
+101
View File
@@ -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 —
+291
View File
@@ -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
+282
View File
@@ -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
+284
View File
@@ -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
+10 -1
View File
@@ -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