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
+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