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:
Regular → Executable
+673
-66
@@ -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
|
||||
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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Executable
+291
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user