Sonarr/Radarr Connection Script (with Mount Safety)

Hello,

The Personal Media feature has been getting better with every update and it's working really nicely with my slightly larger library (~15,000 TV episodes, ~7,500 movies). I wanted to share a script that triggers Channels to scan/prune when Sonarr or Radarr imports or deletes content.

The problem: When Sonarr/Radarr upgrades a file, the filename changes (e.g., from [WEBDL-1080p][h264]-playWEB.mkv to [Bluray-1080p][x265]-iVy.mkv). Channels sees the new file and adds it, but the old database entry for the deleted file remains - resulting in duplicate entries. The built-in scan finds new files but doesn't automatically prune orphaned entries, so duplicates accumulate over time. This script triggers a prune on upgrade/delete to keep things tidy, and a scan on import so new content appears quickly.

Why not auto-prune by default? The community has documented this well - if a drive disconnects, auto-prune would wipe your entire library from the database. The script includes a mount safety check that actually verifies (some) files are accessible before allowing prune to run, which should catch mount failures that a simple database check would miss.

A note on the safety check: The mount verification works by requesting a tiny bit of video data from sample files via the HLS endpoint - if Channels can serve it, the files are genuinely accessible. It checks both a movie and an episode when you have both (to cover separate mounts), or two files of the same type if you only have one. It's the best approach I could come up with, but it's not bulletproof - there may be edge cases I haven't thought of. Use at your own risk, keep backups of your Channels database, and if anyone has ideas for a more robust check, please share!

What it does:

  • Triggers a Personal Media scan on import (new content appears quickly)
  • Prunes orphaned database entries on delete/upgrade (removes stale entries, does NOT delete files)
  • Verifies media files are actually accessible before pruning (requests 1 byte of video data via HLS endpoint)
  • Only affects Personal Media imports - DVR recordings, Stream Links, etc. are untouched
  • Uses official API endpoints

Caveats:

  • Scans are full library scans (Channels checks file modification times, so it's fairly quick)
  • Mount verification adds ~4-5 seconds, but results are cached for 90 seconds to handle batch imports efficiently
  • For massive bulk imports (thousands of files), you might want to disable the script temporarily
  • Fire-and-forget approach means you won't know if a request failed, but Channels handles duplicate requests gracefully
  • If you've extended your scan interval like I have (1 hour instead of the default 5 minutes), the instant scan becomes more valuable

Setup:

  1. Save to /config/scripts/channels-sync.sh in your Sonarr/Radarr config
  2. chmod +x /config/scripts/channels-sync.sh
  3. Edit CHANNELS_URL to match your setup
  4. Settings → Connect → Custom Script with triggers:
    • On File Import, On File Upgrade, On Rename, On Series/Movie Delete, On Episode/Movie File Delete

Hope this helps! I put this together with LLM assistance, so if you've got improvements or a completely different approach, I'm all ears.

Created and test on 27/12/2025
Sonarr Version: 4.0.16.2944 (lscr.io/linuxserver/sonarr)
Radarr Version: 6.0.4.10291 (lscr.io/linuxserver/radarr)
Channels Version: 2025.10.30.0047 (fancybits/channels-dvr:nvidia)

channels-sync.sh

#!/bin/bash
#===============================================================================
# Channels DVR Sync Script for Sonarr/Radarr
#===============================================================================
#
# Triggers Channels DVR to scan for new Personal Media when Sonarr/Radarr
# imports content, and prunes orphaned Personal Media entries when files are
# deleted. Prune only removes database entries - it does NOT delete any files
# and does NOT affect DVR recordings.
#
# SAFETY FEATURE:
#   Before pruning, this script verifies that Personal Media files are actually
#   accessible on disk by requesting video data from sample files. This prevents
#   catastrophic library wipes if a drive disconnects or mount fails.
#
#   Scope: Only checks imported content (source=imports) - does NOT affect:
#          DVR Recordings, Stream Links, PlayOn Cloud, or Stream Files.
#
#   The verification result is cached (default 90s) to avoid API overhead
#   during batch imports.
#
# INSTALLATION:
#   1. Save this script to your Sonarr/Radarr config directory:
#      - Sonarr: /config/scripts/channels-sync.sh (container path)
#      - Radarr: /config/scripts/channels-sync.sh (container path)
#   2. Make executable: chmod +x /config/scripts/channels-sync.sh
#   3. Ensure Unix line endings (LF, not CRLF)
#   4. Edit CHANNELS_URL below to match your setup
#
# SONARR SETUP (Settings -> Connect -> + -> Custom Script):
#   Name: Channels DVR Sync
#   Triggers (in UI order):
#             [ ] On Grab
#             [x] On File Import
#             [x] On File Upgrade
#             [ ] On Import Complete
#             [x] On Rename
#             [ ] On Series Add
#             [x] On Series Delete
#             [x] On Episode File Delete
#             [ ] On Episode File Delete For Upgrade  <- handled by On File Upgrade
#             [ ] On Health Issue
#             [ ] On Health Restored
#             [ ] On Application Update
#             [ ] On Manual Interaction Required
#   Path: /config/scripts/channels-sync.sh
#
# RADARR SETUP (Settings -> Connect -> + -> Custom Script):
#   Name: Channels DVR Sync
#   Triggers (in UI order):
#             [ ] On Grab
#             [x] On File Import
#             [x] On File Upgrade
#             [x] On Rename
#             [ ] On Movie Added
#             [x] On Movie Delete
#             [x] On Movie File Delete
#             [ ] On Movie File Delete For Upgrade  <- handled by On File Upgrade
#             [ ] On Health Issue
#             [ ] On Health Restored
#             [ ] On Application Update
#             [ ] On Manual Interaction Required
#   Path: /config/scripts/channels-sync.sh
#
# REQUIREMENTS:
#   - curl (included in linuxserver.io and hotio images)
#   - Network access from Sonarr/Radarr container to Channels DVR
#
# TESTED WITH:
#   - linuxserver/sonarr, linuxserver/radarr (Alpine-based)
#   - hotio/sonarr, hotio/radarr
#
# API ENDPOINTS USED:
#   GET  /dvr                          - Status/health check
#   GET  /api/v1/movies?limit=1        - Get sample movie ID for mount verification
#   GET  /api/v1/episodes?limit=1      - Get sample episode ID for mount verification
#   GET  /dvr/files/{id}/hls/...       - Verify file is accessible on disk
#   PUT  /dvr/scanner/scan             - Trigger Personal Media scan
#   PUT  /dvr/scanner/imports/prune    - Remove orphaned Personal Media entries
#
#===============================================================================

#-------------------------------------------------------------------------------
# CONFIGURATION
#-------------------------------------------------------------------------------

#-- Connection -----------------------------------------------------------------

CHANNELS_URL="${CHANNELS_URL:-http://172.20.0.1:8089}"   # Your Channels DVR URL

#-- Features -------------------------------------------------------------------

ENABLE_SCAN="${ENABLE_SCAN:-true}"    # Trigger Personal Media scan on import
ENABLE_PRUNE="${ENABLE_PRUNE:-true}"  # Remove orphaned entries on delete

#-- Mount Safety Check ---------------------------------------------------------
# Before pruning, verifies Personal Media files are actually accessible on disk
# by requesting video data from sample files. This prevents library wipes if a
# drive disconnects or mount fails. Only checks "Imports" source - does NOT
# affect DVR Recordings, Stream Links, PlayOn Cloud, or Stream Files.

ENABLE_MOUNT_CHECK="${ENABLE_MOUNT_CHECK:-true}"  # Verify mounts before prune
MOUNT_CHECK_INTERVAL="${MOUNT_CHECK_INTERVAL:-90}" # Cache duration (seconds)

#-- Debugging ------------------------------------------------------------------

DEBUG="${DEBUG:-false}"  # Set "true" to log detailed diagnostics

#-------------------------------------------------------------------------------
# SCRIPT - No need to edit below this line
#-------------------------------------------------------------------------------

# Cache file location - /tmp clears on container restart, forcing fresh check
MOUNT_CHECK_CACHE="/tmp/channels-dvr-mount-verified"

log() { echo "[Channels-Sync] $1"; }

debug() { [ "$DEBUG" = "true" ] && log "DEBUG: $1"; }

#-------------------------------------------------------------------------------
# Mount Verification Functions
#-------------------------------------------------------------------------------
# These functions verify that media files are actually accessible on disk,
# not just present in the Channels DVR database. This prevents the prune
# operation from wiping your library if a drive disconnects or mount fails.
#
# The script automatically detects what content you have and checks 2 files:
#   - Both movies AND TV: 1 movie + 1 episode (verifies both mounts)
#   - Movies only: 2 movies (newest + oldest for variety)
#   - TV only: 2 episodes (newest + oldest for variety)
#   - Neither: skips verification (nothing to protect)
#-------------------------------------------------------------------------------

# Check if we verified mounts recently (within MOUNT_CHECK_INTERVAL seconds)
# Returns 0 (true) if cache is fresh, 1 (false) if stale/missing
mount_check_is_fresh() {
    [ -f "$MOUNT_CHECK_CACHE" ] || return 1
    
    local cache_time current_time age
    
    # Get cache file modification time (seconds since epoch)
    # Using stat with fallback for different implementations
    if stat -c %Y "$MOUNT_CHECK_CACHE" >/dev/null 2>&1; then
        cache_time=$(stat -c %Y "$MOUNT_CHECK_CACHE")
    elif stat -f %m "$MOUNT_CHECK_CACHE" >/dev/null 2>&1; then
        cache_time=$(stat -f %m "$MOUNT_CHECK_CACHE")
    else
        return 1  # Can't determine age, treat as stale
    fi
    
    current_time=$(date +%s)
    age=$((current_time - cache_time))
    
    debug "Mount check cache age: ${age}s (interval: ${MOUNT_CHECK_INTERVAL}s)"
    
    [ "$age" -lt "$MOUNT_CHECK_INTERVAL" ]
}

# Verify a specific file is accessible by requesting 1 byte of video data
# This MUST touch the actual file on disk - it cannot be served from cache
# Returns 0 (true) if file is accessible, 1 (false) otherwise
verify_file_accessible() {
    local file_id="$1"
    local http_code
    
    [ -z "$file_id" ] && return 1
    
    # Request first byte of first video segment
    # HTTP 200 or 206 (partial content) = file accessible
    # HTTP 404 or timeout = file not accessible
    http_code=$(curl -s -o /dev/null -w "%{http_code}" \
        -H "Range: bytes=0-0" \
        --max-time 5 \
        "${CHANNELS_URL}/dvr/files/${file_id}/hls/stream.m3u8/stream0.ts?vcodec=copy" 2>/dev/null)
    
    debug "File $file_id verification: HTTP $http_code"
    
    [ "$http_code" = "200" ] || [ "$http_code" = "206" ]
}

# Get sample file IDs from the library for verification
# Uses limit=1 and sort order to get specific files efficiently
get_movie_id() {
    local order="${1:-desc}"  # desc=newest, asc=oldest
    curl -s --max-time 10 \
        "${CHANNELS_URL}/api/v1/movies?source=imports&limit=1&sort=date_added&order=${order}" 2>/dev/null \
        | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4
}

get_episode_id() {
    local order="${1:-desc}"  # desc=newest, asc=oldest
    curl -s --max-time 10 \
        "${CHANNELS_URL}/api/v1/episodes?source=imports&limit=1&sort=date_added&order=${order}" 2>/dev/null \
        | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4
}

# Main mount verification function
# Checks 2 files total based on what content exists in the library
# Returns 0 (true) if mounts OK, 1 (false) if verification failed
verify_mounts() {
    local file1="" file2=""
    local has_movies="" has_episodes=""
    
    debug "Starting mount verification..."
    
    # Check what content types exist (newest of each)
    has_movies=$(get_movie_id desc)
    has_episodes=$(get_episode_id desc)
    
    debug "Content check: movies=${has_movies:-none}, episodes=${has_episodes:-none}"
    
    # Determine which 2 files to check based on available content
    if [ -n "$has_movies" ] && [ -n "$has_episodes" ]; then
        # Has BOTH: check 1 movie + 1 episode (verifies both mount points)
        file1="$has_movies"
        file2="$has_episodes"
        debug "Strategy: 1 movie + 1 episode (both mounts)"
        
    elif [ -n "$has_movies" ]; then
        # Movies ONLY: check newest + oldest movie (variety/redundancy)
        file1="$has_movies"
        file2=$(get_movie_id asc)
        debug "Strategy: 2 movies (newest + oldest)"
        
    elif [ -n "$has_episodes" ]; then
        # Episodes ONLY: check newest + oldest episode (variety/redundancy)
        file1="$has_episodes"
        file2=$(get_episode_id asc)
        debug "Strategy: 2 episodes (newest + oldest)"
        
    else
        # No Personal Media at all - nothing to protect
        debug "No Personal Media found - skipping verification"
        return 0
    fi
    
    # Verify first file
    debug "Checking file 1: $file1"
    if ! verify_file_accessible "$file1"; then
        log "ERROR: Mount verification FAILED for file ID: $file1"
        return 1
    fi
    
    # Verify second file (if different from first)
    if [ -n "$file2" ] && [ "$file2" != "$file1" ]; then
        debug "Checking file 2: $file2"
        if ! verify_file_accessible "$file2"; then
            log "ERROR: Mount verification FAILED for file ID: $file2"
            return 1
        fi
    fi
    
    debug "Mount verification passed"
    return 0
}

# Wrapper that handles caching of mount verification results
# This prevents redundant API calls during batch imports
check_mounts_with_cache() {
    # Check if we verified recently
    if mount_check_is_fresh; then
        debug "Mount verification cached - skipping"
        return 0
    fi
    
    # Need fresh verification
    debug "Mount verification cache stale/missing - verifying..."
    
    if verify_mounts; then
        # Success - update cache timestamp
        touch "$MOUNT_CHECK_CACHE"
        debug "Mount verification passed - cache updated"
        return 0
    else
        # Failed - remove cache to force re-check next time
        rm -f "$MOUNT_CHECK_CACHE"
        return 1
    fi
}

#-------------------------------------------------------------------------------
# Core Functions
#-------------------------------------------------------------------------------

# Check Channels DVR is running and reachable
channels_ok() {
    curl -s --max-time 5 "${CHANNELS_URL}/dvr" 2>/dev/null | grep -q '"status":"running"'
}

# Legacy safety check - verify database shows content
# Kept as secondary check alongside mount verification
library_has_content() {
    curl -s --max-time 5 "${CHANNELS_URL}/dvr" 2>/dev/null | grep -q '"files":[1-9]'
}

# Trigger Personal Media scan
do_scan() {
    if [ "$ENABLE_SCAN" != "true" ]; then
        debug "Scan skipped (disabled)"
        return 0
    fi
    
    # Fire and forget - Channels deduplicates rapid requests internally
    curl -s -X PUT --max-time 2 "${CHANNELS_URL}/dvr/scanner/scan" >/dev/null 2>&1 &
    log "Scan requested"
}

# Prune orphaned Personal Media entries (removes DB entries, not files)
do_prune() {
    if [ "$ENABLE_PRUNE" != "true" ]; then
        debug "Prune skipped (disabled)"
        return 0
    fi
    
    # Safety check 1: Verify database shows content
    if ! library_has_content; then
        log "ERROR: Prune blocked - library appears empty or unreachable"
        return 1
    fi
    
    # Safety check 2: Verify mounts are accessible (unless disabled)
    if [ "$ENABLE_MOUNT_CHECK" = "true" ]; then
        if ! check_mounts_with_cache; then
            log "ERROR: Prune blocked - mount verification failed"
            log "       This prevents library wipe if drive disconnected"
            return 1
        fi
    else
        debug "Mount check disabled - skipping"
    fi
    
    # All safety checks passed - proceed with prune
    curl -s -X PUT --max-time 2 "${CHANNELS_URL}/dvr/scanner/imports/prune" >/dev/null 2>&1 &
    log "Prune requested"
}

#-------------------------------------------------------------------------------
# Main Script
#-------------------------------------------------------------------------------

# Detect calling application and extract event details
if [ -n "$sonarr_eventtype" ]; then
    EVENT="$sonarr_eventtype"
    UPGRADE="$sonarr_isupgrade"
    TITLE="$sonarr_series_title"
    APP="Sonarr"
elif [ -n "$radarr_eventtype" ]; then
    EVENT="$radarr_eventtype"
    UPGRADE="$radarr_isupgrade"
    TITLE="$radarr_movie_title"
    APP="Radarr"
else
    log "ERROR: Not called from Sonarr/Radarr (missing event variables)"
    exit 0
fi

# Debug mode - log configuration
if [ "$DEBUG" = "true" ]; then
    log "DEBUG: APP=${APP} EVENT=${EVENT} UPGRADE=${UPGRADE} TITLE=${TITLE}"
    log "DEBUG: CHANNELS_URL=${CHANNELS_URL}"
    log "DEBUG: ENABLE_SCAN=${ENABLE_SCAN} ENABLE_PRUNE=${ENABLE_PRUNE} ENABLE_MOUNT_CHECK=${ENABLE_MOUNT_CHECK}"
    log "DEBUG: MOUNT_CHECK_INTERVAL=${MOUNT_CHECK_INTERVAL}s"
fi

# Verify Channels DVR is reachable before proceeding
if ! channels_ok; then
    log "ERROR: Channels DVR unreachable at ${CHANNELS_URL}"
    exit 0
fi

# Handle events from Sonarr/Radarr
case "$EVENT" in
    Test)
        log "Connection test successful (${APP} -> Channels DVR at ${CHANNELS_URL})"
        log "Settings: ENABLE_SCAN=${ENABLE_SCAN}, ENABLE_PRUNE=${ENABLE_PRUNE}, ENABLE_MOUNT_CHECK=${ENABLE_MOUNT_CHECK}"
        
        # On test, run full mount verification (ignoring cache) to confirm it works
        if [ "$ENABLE_MOUNT_CHECK" = "true" ]; then
            log "Running mount verification..."
            if verify_mounts; then
                log "Mount verification: PASSED"
            else
                log "Mount verification: FAILED (check file accessibility)"
            fi
        fi
        ;;
    Download)
        log "Imported: ${TITLE}"
        # On upgrade, prune old entry BEFORE triggering scan
        if [ "$UPGRADE" = "True" ] || [ "$UPGRADE" = "true" ]; then
            do_prune
        fi
        do_scan
        ;;
    EpisodeFileDelete|MovieFileDelete)
        log "File deleted: ${TITLE}"
        do_prune
        ;;
    SeriesDelete|MovieDelete)
        log "Deleted: ${TITLE}"
        do_prune
        ;;
    Rename)
        log "Renamed: ${TITLE}"
        # Rename = file moved, so prune old path and scan for new
        do_prune
        do_scan
        ;;
    *)
        log "Unhandled event: ${EVENT}"
        ;;
esac

exit 0
2 Likes